合入集成分支 feat/data-catalog(d897981,含 F2a 的证据面板、时间线和服务端修复):自动合并,没有冲突;catalog_profile._relation_finding 两边的改动(F2b 的 previous_status、F2a 的 cardinality_checked 写在关系上)互不重叠

This commit is contained in:
ThearchyHelios
2026-10-04 02:46:01 +08:00
45 changed files with 2007 additions and 278 deletions
+13 -3
View File
@@ -227,7 +227,11 @@ async def put_catalog_table(source_id: str, table: str, payload: CatalogPutIn,
async def draft_catalog(source_id: str, payload: CatalogDraftIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""同步起草。返回每张表新增、更新、删除了几项;模型用不了或某张表的模型起草失败,照实写在结果里。"""
"""同步起草。返回每张表新增、更新、删除了几项;模型用不了或某张表的模型起草失败,照实写在结果里。
model_items:模型给了这张表几项可用内容(没用模型、模型这部分失败时为 null)。界面据此区分「模型给了内容、
只是和现有目录一致」(model_items > 0、三个数都是 0)和「模型没给内容」(model_error 有值)。
"""
_, source = await _resolved(session, source_id)
cache = source.schema_cache or {}
if not cache.get("tables"):
@@ -235,7 +239,8 @@ async def draft_catalog(source_id: str, payload: CatalogDraftIn,
report = await catalog.draft_catalog(session, source, tables=payload.tables or None,
use_model=payload.use_model, actor=actor_of(x_actor))
rows = [{"table_name": r.table, "added": r.added, "updated": r.updated, "removed": r.removed,
"version": r.version, "error": r.error, "model_error": r.model_error} for r in report.tables]
"version": r.version, "error": r.error, "model_error": r.model_error, "model_items": r.model_items}
for r in report.tables]
return {
"tables": rows,
"total": {k: sum(r[k] for r in rows) for k in ("added", "updated", "removed")},
@@ -287,7 +292,10 @@ async def profile_catalog_tables(source_id: str, payload: CatalogProfileIn,
raise HTTPException(409, str(e)) from e
rows = [_profile_table_out(t) for t in report.tables]
note = None
if not rows:
if not rows and report.empty_tables:
note = (f"有待核对关联关系的 {len(report.empty_tables)} 张表都是空表,没有可以核实的数据。"
"请在数据有了之后再剖析,或指定要剖析的表")
elif not rows:
note = "目录里还没有待核实的关联关系,请先起草数据目录,或指定要剖析的表"
return {
"profiled_at": report.profiled_at,
@@ -297,6 +305,8 @@ async def profile_catalog_tables(source_id: str, payload: CatalogProfileIn,
"stopped": report.stopped,
"tables": rows,
"total": {k: sum(r[k] for r in rows) for k in ("added", "updated", "removed")},
# 不指定表时挑表途中确知是空表、跳过了的表(指定了表时总是空列表)
"empty_tables": report.empty_tables,
"note": note,
}
+4 -1
View File
@@ -219,7 +219,10 @@ NODE_REFERENCE = """\
查过的数据源里真实存在的名字,表结构、查询、结果列里都没有的会被标成「可能是编造的名字」(受管模板的正式出具因此降档)
- [[q:K1|逐字引文]]:知识库检索的原话,必须和命中片段里的字逐字一致(空白不计,至少 4 个字),改一个字就算
没有出处;只是拿检索当依据时写 [[see:K1]]。要引原话,在 report 前面接 retrieve 节点
- 句末的 [[see:m:指标id,Q1,K1]]:这句结论的依据,不显示
- 句末的 [[see:m:指标id,Q1,K1]]:这句结论的依据,不显示,所以只能放在句末;instructions 里不要写「以 [[see:…]] 为依据」
「根据 [[see:…]]」这样把它当成句子成分的示例,模型照着写会留下「以 为依据」这样的断句
- 涉及码值(状态、类型这类用数字编码的取值)时,instructions 里让模型写它的含义(「作废记录」),不要写码值本身:
码值是没有出处的数字,会被数字核对打回重写
metrics_from 不写就取所有上游口径卡;on_violation 不写时探索运行是 flag(标出来、照常产出),正式运行是 fail;
numbers 默认 strict(裸数字算违规)。claims 是结论句策略:off(默认,不管)/ require_citation(每句结论都要
挂引用,没挂的计入缺口、出具降档)/ judge(在 require_citation 之外,再请另一个模型按每句挂的依据判断支不支持:
+49 -2
View File
@@ -983,7 +983,8 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str
# 查询时从驱动的原始值记下的列类型(老快照没有):文本列里的 "2026" 按文本显示,不当数
column_types=snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {},
**({"provenance": True} if hint and snap.get("data_version") and not merged else {}),
**({"checks": checks} if (checks := _query_checks(snap)) else {}))
**({"checks": checks} if (checks := _query_checks(snap)) else {}),
**_catalog_of(snap))
if len(index) > MAX_WINDOW_ROWS:
index, step["window_truncated"] = index[:MAX_WINDOW_ROWS], True
step["row_index"] = index
@@ -993,6 +994,38 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str
return step
def _catalog_of(snap: dict[str, Any]) -> dict[str, Any]:
"""{schema_artifact, catalog}:这条查询当时冻结的表结构快照,以及它用到的表在快照里冻结的目录版本。
数据源查询工具每次查询都把当时的表结构和数据目录冻结进表结构快照(tools/datasource._store_schema),SQL 检查
对照的就是这一版。面板上写「数据目录:入园记录 第 3 版」:事后目录改了、升了版本,看得出这次查询按的是哪一版。
catalog 只列 SQL 用到、而且冻结了目录的表:[{table, label?, version}]。这条查询没对照过目录(合并查询、老快照、
没探查过结构或没有目录的源、表结构快照读不出来)时两个键都不加:步骤的形状和以前一字不差(期 4 的金样逐字比对)。
"""
schema_id = snap.get("schema_artifact")
if not isinstance(schema_id, str) or not schema_id:
return {}
try:
schema = load(schema_id)
except Exception: # noqa: BLE001 - 读不出来:不编版本
return {}
frozen = schema.get("catalog") if isinstance(schema, dict) else None
if not isinstance(frozen, dict) or not frozen:
return {}
from app.data.catalog import resolve_table_name
listed: list[dict[str, Any]] = []
for name in sql_tables(str(snap.get("sql") or "")):
key = resolve_table_name(schema, name)
entry = frozen.get(key) if key else None
if not isinstance(entry, dict) or any(t["table"] == key for t in listed):
continue
label = ((entry.get("notes") or {}).get("label") or {}).get("value")
listed.append({"table": key, **({"label": label} if isinstance(label, str) and label else {}),
"version": entry.get("version")})
return {"schema_artifact": schema_id, "catalog": listed} if listed else {}
#: 查询步骤里每条 SQL 检查结果交给界面的字段(data/sqlcheck.SqlCheck.as_dict 去掉 for_model)
_CHECK_FIELDS = ("code", "level", "message", "table", "column", "relation_id", "sql_excerpt")
@@ -1052,7 +1085,10 @@ def _merge_view(snap: dict[str, Any], cells: list[tuple[Any, Any]], catalog: dic
inputs.append({"alias": item.get("alias"), "node_id": item.get("node_id"),
"label": labels.get(str(item.get("node_id"))), "query": _query_alias(catalog, art),
"rows": item.get("rows"), "source": item.get("source"), "artifact": art or None,
"sealed": sealed.trusted and art in sealed.queries})
"sealed": sealed.trusted and art in sealed.queries,
# 输入查询的 SQL 检查结果:被引用的格追不到逐格来历时没有输入步骤,指标却因为它标了「存疑」,
# 问题得在合并步骤里看得到。没查出问题、快照读不出来的输入不加这个键
**({"checks": checks} if (checks := _input_checks(art)) else {})})
traced = []
for row, column in dict.fromkeys((r, c) for r, c in cells if isinstance(r, int) and c):
hit = trace_cell(snap, row, column)
@@ -1067,6 +1103,17 @@ def _merge_view(snap: dict[str, Any], cells: list[tuple[Any, Any]], catalog: dic
return {"sql": snap.get("sql"), "inputs": inputs, "warnings": warnings, "traced": traced}
def _input_checks(artifact: str) -> list[dict[str, Any]]:
"""合并查询某个输入的 SQL 检查结果(_query_checks 的形状)。读不出来的快照返回空列表:快照的问题输入步骤另有说法。"""
if not artifact:
return []
try:
snap = load(artifact)
except Exception: # noqa: BLE001 - 被改过、读不出来:这里不报
return []
return _query_checks(snap) if isinstance(snap, dict) else []
async def _merge_masks(snap: dict[str, Any], masks: _Masks) -> tuple[set[str], list[str]]:
"""合并结果要遮的列(小写)和已经不在的数据源:
+103 -34
View File
@@ -18,7 +18,11 @@ notes 结构(每个「项」都是 {value, source, status},可选 note、upd
"codes": 项(dict[码值, 含义];含义可为空串,表示待填写;项上可带 complete: true,
表示已列出全部取值)}},
"relations": [{"id", "columns", "to_table", "to_columns", "cardinality", "coverage",
"source", "status", "note"?}]}
"source", "status", "note"?, "cardinality_checked"?}]}
关系上的 cardinality_checked: true 只由数据剖析写:基数是用数据核实的(子表一侧数过 COUNT 和 COUNT(DISTINCT),
或者有主键、唯一约束)。外键约束、命名推断给的基数是按表结构推的,子表一侧可能每个键恰好一行;SQL 检查只在
基数用数据核实过、或者人工确认过时,才把一对多关联后的重复计算报成错误(data/sqlcheck.py)。
来源(source):comment 数据库注释、fk 外键约束、name 命名推断、profile 数据剖析(data/catalog_profile.py)、
llm 模型起草、human 人工填写。状态(status):proposed 推断(命名推断、模型起草、数据库注释)、verified 有确证
@@ -40,7 +44,7 @@ import hashlib
import json
import re
from collections import Counter
from collections.abc import Iterable, Mapping
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@@ -88,7 +92,7 @@ _ITEM_KEYS = frozenset({"value", "source", "status", "note", "updated_at"})
#: 没截断地看完了全表取值时才有;没有它的码值表只是「已知的一部分」,SQL 检查不能拿它判断某个值「不在码值表里」
_CODES_ITEM_KEYS = _ITEM_KEYS | {"complete"}
_RELATION_KEYS = frozenset({"id", "columns", "to_table", "to_columns", "cardinality", "coverage", "source",
"status", "note", "updated_at"})
"status", "note", "updated_at", "cardinality_checked"})
_BUSINESS_DATE_KEYS = frozenset({"column", "rule", "timezone"})
#: 文本项的长度上限。目录是给模型和人读的短说明,长篇大论放口径卡或知识库
_TEXT_MAX = 500
@@ -293,6 +297,8 @@ def _relation_problems(rel: Any, seen: set[str]) -> list[str]:
problems.append(f"{where}的状态「{rel.get('status')}」不在可选值内")
if "note" in rel and not isinstance(rel["note"], str):
problems.append(f"{where}的备注应为文字")
if "cardinality_checked" in rel and not isinstance(rel["cardinality_checked"], bool):
problems.append(f"{where}的「基数已用数据核实」应为是或否")
if "updated_at" in rel and not isinstance(rel["updated_at"], str):
problems.append(f"{where}的修改时间应为文字")
return problems
@@ -512,22 +518,35 @@ def _core(item: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in item.items() if k != "updated_at"}
#: 数据剖析写的备注都以「数据剖析(日期):」开头(data/catalog_profile.py 的 NOTE_PREFIX 就是它)
PROFILE_NOTE_PREFIX = "数据剖析("
_PROFILE_NOTE_DAY = re.compile(r"^数据剖析(\d{4}-\d{2}-\d{2})")
#: 数据剖析写的备注都以「数据剖析:」开头(data/catalog_profile.py 的 NOTE_PREFIX 就是它)。说明里不写日期:以前写的是
#: UTC 日期,和界面上按本地时间显示的修改时间对不上;什么时候剖析的,看这一项的 updated_at
PROFILE_NOTE_PREFIX = "数据剖析:"
#: 升级前的剖析备注带着当天的 UTC 日期:「数据剖析(2026-10-01):」。旧数据里还有,照样认
_PROFILE_NOTE_DAY = re.compile(r"^数据剖析(\d{4}-\d{2}-\d{2})(?=:)")
def is_profile_note(note: Any) -> bool:
"""这段备注是不是数据剖析写的(新写法「数据剖析:」,或者升级前带日期的「数据剖析(日期):」)。"""
return isinstance(note, str) and (note.startswith(PROFILE_NOTE_PREFIX) or bool(_PROFILE_NOTE_DAY.match(note)))
def _undated(note: str) -> str:
"""剖析备注去掉升级前写的日期:「数据剖析(2026-10-01):…」→「数据剖析:…」。别的备注原样返回。"""
return _PROFILE_NOTE_DAY.sub("数据剖析", note, count=1)
def same_profile_note(a: Any, b: Any) -> bool:
"""两段备注是否相同;数据剖析写的备注只有开头的日期不同,也算相同。
"""两段备注是否相同;数据剖析写的备注只差开头的日期(升级前每次都写当天的日期),或者一段带日期、一段是不带
日期的新写法,也算相同。
剖析每次都把当天的日期写进备注。隔天重跑、数据没变,结论一字不差、只有日期变了:要是这也算「变了」,目录就
升一个版本,发布时记下的目录版本对不上,之后每次正式运行都提醒「目录有变化」。
隔天重跑、数据没变,结论一字不差、只有日期变了:要是这也算「变了」,目录就升一个版本,发布时记下的目录版本
对不上,之后每次正式运行都提醒「目录有变化」。升级后第一次重新剖析也是一样:结论没变,只是新写法不带日期。
"""
if a == b:
return True
if not (isinstance(a, str) and isinstance(b, str) and _PROFILE_NOTE_DAY.match(a) and _PROFILE_NOTE_DAY.match(b)):
if not (is_profile_note(a) and is_profile_note(b)):
return False
return _PROFILE_NOTE_DAY.sub("", a, count=1) == _PROFILE_NOTE_DAY.sub("", b, count=1)
return _undated(a) == _undated(b)
def _same_core(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
@@ -742,7 +761,8 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
for key, item in new.items():
prev = old.get(key)
if prev is None or _edit_value(key, item) != _edit_value(key, prev):
fresh = {k: v for k, v in item.items() if k != "updated_at"}
# 「基数已用数据核实」只由数据剖析写:人工改过的关系记成人工确认,提交里带的这个标记不作数
fresh = {k: v for k, v in item.items() if k not in ("updated_at", "cardinality_checked")}
fresh.update(source="human", status="confirmed", updated_at=at)
out[key] = fresh
continue
@@ -1576,8 +1596,16 @@ def _batches(names: list[str], tables: Mapping[str, Any]) -> list[list[str]]:
return out
#: 模型给了这张表、但一项可用内容都没有(只有表名的空壳,或者给的值全不合规):这张表记模型失败,不算起草成功。
#: 不然界面只看到「新增 0 项」,会说成「没有变化」
MODEL_EMPTY_REASON = "模型没有给出这张表的可用内容"
def _model_tables(raw: Any) -> list[dict[str, Any]] | None:
"""模型的回复 → 每张表一项的列表。结构化输出给的对象、正文里的 JSON 都认;对不上格式返回 None。"""
"""模型的回复 → 每张表一项的列表。结构化输出给的对象、正文里的 JSON 都认;对不上格式返回 None。
实测模型有时不带 tables 外壳、只回单个表对象({"table": "x", "label": …}),也认成一张表。
"""
if hasattr(raw, "model_dump"):
raw = raw.model_dump()
if isinstance(raw, str):
@@ -1591,31 +1619,43 @@ def _model_tables(raw: Any) -> list[dict[str, Any]] | None:
except ValueError:
return None
if isinstance(raw, dict):
raw = raw.get("tables")
raw = [raw] if "tables" not in raw and raw.get("table") else raw.get("tables")
if not isinstance(raw, list):
return None
return [t for t in raw if isinstance(t, dict)]
async def _ask_model(model: Any, messages: list[Any]) -> list[dict[str, Any]]:
"""问一批。先走结构化输出;模型不支持、调用报错、或给回来的不是约定的结构,再退回纯文本、从正文里取 JSON。
async def _ask_model(model: Any, messages: list[Any],
usable: Callable[[list[dict[str, Any]]], bool] | None = None) -> list[dict[str, Any]]:
"""问一批。先走结构化输出;模型不支持、调用报错、给回来的不是约定的结构,或者 usable 说它不够用(实测见过
结构化输出只给 {"tables":[{"table":"x"}]} 这样的空壳),再退回纯文本、从正文里取 JSON。
结构化输出报错也退回(同 judge._ask):有的网关不支持工具调用,一调就报错,纯文本却能用。代价是真的
网络故障会多试一次,两次加起来仍受 MODEL_TIMEOUT_S 约束。
两条路都拿到了东西时,结构化的在前、纯文本的在后一起交回(同一张表各有一份,调用方挑能用的那份);纯文本那次
失败了就只交回结构化的那份(调用方据此给空壳的表记失败)。
"""
try:
parsed = _model_tables(await model.with_structured_output(CATALOG_DRAFT_PROMPT_SCHEMA).ainvoke(messages))
except Exception: # noqa: BLE001 - 退回纯文本
parsed = None
if parsed is not None:
if parsed is not None and (usable is None or usable(parsed)):
return parsed
from app.engine.state import message_text
reply = await model.ainvoke(messages)
parsed = _model_tables(message_text(reply))
if parsed is None:
try:
reply = await model.ainvoke(messages)
except Exception: # noqa: BLE001 - 结构化那份还在:交回它,空壳的表由调用方记失败
if parsed is not None:
return parsed
raise
text = _model_tables(message_text(reply))
if text is None:
if parsed is not None:
return parsed
raise ValueError("模型的回复不是约定的格式")
return parsed
return [*(parsed or []), *text]
def _clean_text(value: Any, limit: int = _TEXT_MAX) -> str | None:
@@ -1695,9 +1735,22 @@ async def draft_with_model(model: Any, source: Any, tables: list[str], *,
existing=(existing or {}).get(n)) for n in batch)
messages = [SystemMessage(content=CATALOG_DRAFT_SYSTEM),
HumanMessage(content=CATALOG_DRAFT_PROMPT.format(n=len(batch), tables=described))]
def drafted(entries: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""这批里每张表能用的那份:同一张表给了几份(结构化、纯文本各一份)时取第一份有可用内容的。"""
out: dict[str, dict[str, Any]] = {}
for entry in entries:
name = str(entry.get("table"))
if name in batch and name not in out:
notes = _from_model(entry, all_tables[name], system=system)
if notes:
out[name] = notes
return out
try:
async with gate:
got = await asyncio.wait_for(_ask_model(model, messages), timeout=MODEL_TIMEOUT_S)
got = await asyncio.wait_for(
_ask_model(model, messages, usable=lambda entries: len(drafted(entries)) == len(batch)),
timeout=MODEL_TIMEOUT_S)
except asyncio.TimeoutError:
for n in batch:
results[n] = f"模型起草超时:{MODEL_TIMEOUT_S} 秒内没有收到回复"
@@ -1708,14 +1761,13 @@ async def draft_with_model(model: Any, source: Any, tables: list[str], *,
for n in batch:
results[n] = f"模型起草失败:{first_line(e) or describe_exception(e)}"
return
by_name = {str(t.get("table")): t for t in got}
named = {str(t.get("table")) for t in got}
usable = drafted(got)
for n in batch:
entry = by_name.get(n)
if entry is None:
results[n] = "模型的回复里没有这张表"
continue
results[n] = TableDraft(notes=_from_model(entry, all_tables[n], system=system),
covered=frozenset({"llm"}))
if n in usable:
results[n] = TableDraft(notes=usable[n], covered=frozenset({"llm"}))
else:
results[n] = MODEL_EMPTY_REASON if n in named else "模型的回复里没有这张表"
await asyncio.gather(*(run(b) for b in _batches(names, all_tables)))
return {n: results[n] for n in names if n in results}
@@ -1737,6 +1789,9 @@ class TableDraftResult:
version: int = 0
error: str | None = None
model_error: str | None = None
#: 模型给了这张表几项可用内容(并入目录之前,按项数)。没用模型、模型这部分失败时为 None。
#: 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
model_items: int | None = None
@dataclass
@@ -1800,6 +1855,8 @@ async def draft_catalog(session: AsyncSession, source: Any, *, tables: list[str]
modeled = llm.get(table)
if isinstance(modeled, str):
result.model_error = modeled
elif isinstance(modeled, TableDraft):
result.model_items = len(_slots(modeled.notes))
for _attempt in range(3):
entry = await read_entry(session, source.id, table)
version = entry.version if entry else 0
@@ -1991,7 +2048,8 @@ _FLIP = {"many_to_one": "one_to_many", "one_to_many": "many_to_one", "one_to_one
class JoinEdge:
"""关系图里的一条有向边:从 from_table 的 from_columns 连到 to_table 的 to_columns。
每条关系在图里有正反两条边;cardinality 是从 from_table 看过去的基数。
每条关系在图里有正反两条边;cardinality 是从 from_table 看过去的基数。cardinality_checked:基数是数据剖析
用数据核实的(关系上的同名字段),不是按表结构推的。
"""
from_table: str
@@ -2002,10 +2060,11 @@ class JoinEdge:
cardinality: str | None
source: str
status: str
cardinality_checked: bool = False
def reversed(self) -> "JoinEdge":
return JoinEdge(self.to_table, self.to_columns, self.from_table, self.from_columns, self.relation_id,
_FLIP.get(self.cardinality or ""), self.source, self.status)
_FLIP.get(self.cardinality or ""), self.source, self.status, self.cardinality_checked)
def condition(self) -> str:
"""连接条件:visits.id = channel_visits.visit_id(多列用 AND 连接)。"""
@@ -2049,7 +2108,7 @@ def relation_graph(notes_by_table: Mapping[str, Mapping[str, Any]], *,
continue
edge = JoinEdge(table, tuple(rel.get("columns") or ()), str(rel.get("to_table")),
tuple(rel.get("to_columns") or ()), rid, rel.get("cardinality"), str(rel.get("source")),
str(rel.get("status")))
str(rel.get("status")), rel.get("cardinality_checked") is True)
graph.setdefault(edge.from_table, []).append(edge)
graph.setdefault(edge.to_table, []).append(edge.reversed())
return graph
@@ -2163,6 +2222,10 @@ def frozen_catalog(entries: Mapping[str, CatalogEntry], tables: Iterable[str]) -
只收表结构快照里有的表(tables);去掉驳回项以后什么都不剩的表不收。一张也没有时返回空 dict,
调用方据此不加这个键:没有目录的源,快照内容和以前一字不差。目录不变(版本和内容都不变)则返回值
不变,快照哈希也就不变。
被驳回的关系留下编号和状态({"id", "status": "rejected"},不放内容):拿冻结的这份重建 SQL 检查器
(sqlcheck.frozen_checker)时,关系图(relation_graph)认得这些编号,按外键、命名现推的关系不会把驳回的推回来。
只有驳回关系的表也因此要冻结。visible_notes 照样把它们去掉,给人看、给模型看的内容不变。
"""
wanted = set(tables)
out: dict[str, dict[str, Any]] = {}
@@ -2170,6 +2233,10 @@ def frozen_catalog(entries: Mapping[str, CatalogEntry], tables: Iterable[str]) -
if name not in wanted:
continue
notes = visible_notes(entries[name].notes)
rejected = [{"id": str(item["id"]), "status": "rejected"} for key, item in _slots(entries[name].notes).items()
if key[0] == "r" and item.get("status") == "rejected"]
if rejected:
notes = {**notes, "relations": [*(notes.get("relations") or []), *rejected]}
if notes:
out[name] = {"version": entries[name].version, "notes": notes}
return out
@@ -2180,11 +2247,13 @@ __all__ = [
"CATALOG_DRAFT_SYSTEM", "COLUMN_FIELDS", "COLUMN_FIELD_LABEL", "COLUMN_MEASURES", "CatalogConflict",
"CatalogEntry", "CatalogInvalid", "CatalogModelUnavailable", "CatalogPathError", "DRAFT_DEFAULT_TABLES",
"DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL", "MEASURE_LABEL",
"MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PROFILE_NOTE_PREFIX", "PROFILE_VERIFY_COVERAGE",
"MODEL_EMPTY_REASON", "MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PROFILE_NOTE_PREFIX",
"PROFILE_VERIFY_COVERAGE",
"PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL",
"TABLE_KINDS", "TableDraft", "TableDraftResult", "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit",
"apply_patch", "codes_complete", "describe_slot", "draft_catalog", "draft_structure", "draft_with_model",
"fk_relations", "frozen_catalog", "infer_name_relations", "initial_status", "is_date_column", "join_paths",
"fk_relations", "frozen_catalog", "infer_name_relations", "initial_status", "is_date_column", "is_profile_note",
"join_paths",
"make_item", "merge_notes", "name_words", "now_iso", "parse_path", "plan_patch", "profile_relation_holds",
"query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id", "render_table_index",
"render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry", "review_item", "same_notes",
+72 -23
View File
@@ -440,12 +440,14 @@ async def estimate_size(run: _Runner, dialect: SqlDialect, meta: dict[str, Any],
# 一次剖析:上下文、结果
# ==========================================================================
#: 不指定表时剖析几张:按使用次数取目录里有待核实关系的表
#: 不指定表时剖析几张:目录里有要核对的关系的表,还有待核实(推断)关系的在前,再按使用次数;确知是空表的跳过
PROFILE_DEFAULT_TABLES = 10
#: 不指定表时,为跳过空表最多先估几张候选表的行数;估行数的查询最多用掉查询次数上限的一半,免得都花在挑表上
PROFILE_PROBE_TABLES = 30
#: IN 列表每批最多几个值:Oracle 的 IN 列表不能超过 1000 项(ORA-01795),各家统一按它分批
IN_CHUNK = 1000
#: 剖析写的备注都以它开头:人工确认过的关系,只覆盖剖析自己写的备注,不碰人写的。开头接着剖析的日期,
#: 比较「结论变没变」时日期不算(catalog.same_profile_note)
#: 剖析写的备注都以它开头(「数据剖析:」):人工确认过的关系,只覆盖剖析自己写的备注,不碰人写的(认的时候连
#: 升级前带日期的「数据剖析(日期):」一起认,catalog.is_profile_note)。不写日期,时间看这一项的 updated_at
NOTE_PREFIX = catalog.PROFILE_NOTE_PREFIX
#: 整次剖析停下的原因 → 剩下没查的项的说明
_STOP_DETAIL = {
@@ -512,6 +514,8 @@ class ProfileReport:
tables: list[TableProfile] = field(default_factory=list)
queries_used: int = 0
stopped: str | None = None
#: 不指定表时,挑表途中估过行数、确知是空表而跳过的表(表结构顺序)。估行数的查询算在 queries_used 里
empty_tables: list[str] = field(default_factory=list)
@dataclass
@@ -526,6 +530,9 @@ class _RelationOutcome:
sample: int
matched: int
note: str
#: 基数是用数据核实的:子表一侧是否唯一有了定论(主键、唯一约束,或数过 COUNT 和 COUNT(DISTINCT))。
#: 子表太大、没数成的按多对一记,但不算核实——SQL 检查据此不把一对多关联后的重复计算报成错误
cardinality_checked: bool = False
@dataclass
@@ -572,8 +579,6 @@ class _Context:
tables: dict[str, Any]
#: 遮罩的列(小写)。遮罩按列名生效、不分大小写、不分表,和证据面板的比法一致
masked: set[str]
#: 写进备注的剖析日期(UTC)
day: str
sizes: dict[str, TableSize] = field(default_factory=dict)
async def size(self, table: str, result: TableProfile) -> TableSize:
@@ -663,10 +668,11 @@ async def _unique(cx: _Context, table: str, meta: dict[str, Any], column: str,
return total == distinct, "count"
def _relation_note(day: str, *, sample: int, matched: int, coverage: float, scan_cap: int | None,
def _relation_note(*, sample: int, matched: int, coverage: float, scan_cap: int | None,
skipped_values: int, parent: tuple[bool | None, str], child: tuple[bool | None, str] | None,
holds: bool) -> str:
"""关系的剖析备注:日期、抽样规模、覆盖率、父键和子键唯一的依据。人据此判断这条结论有多可靠。"""
"""关系的剖析备注:抽样规模、覆盖率、父键和子键唯一的依据。人据此判断这条结论有多可靠。
不写日期:什么时候剖析的看这一项的 updated_at(界面按本地时间显示)。"""
parts: list[str] = []
low = coverage < catalog.PROFILE_VERIFY_COVERAGE
if low:
@@ -698,7 +704,7 @@ def _relation_note(day: str, *, sample: int, matched: int, coverage: float, scan
parts.append("未能核对子表一侧是否唯一,按多对一记")
if not holds and not low:
parts.append("暂不升为有确证")
return f"{NOTE_PREFIX}{day}):" + ";".join(parts) + "。"
return NOTE_PREFIX + ";".join(parts) + "。"
async def _check_relation(cx: _Context, table: str, rel: dict[str, Any], out: _TableOutcome,
@@ -749,12 +755,13 @@ async def _check_relation(cx: _Context, table: str, rel: dict[str, Any], out: _T
if child_unique is not None:
cardinality = "one_to_one" if child_unique[0] is True else "many_to_one"
holds = coverage >= catalog.PROFILE_VERIFY_COVERAGE and cardinality is not None
note = _relation_note(cx.day, sample=len(literals), matched=matched, coverage=coverage, scan_cap=scan_cap,
note = _relation_note(sample=len(literals), matched=matched, coverage=coverage, scan_cap=scan_cap,
skipped_values=len(values) - len(literals), parent=parent_unique, child=child_unique,
holds=holds)
checked = child_unique is not None and child_unique[0] is not None
out.relations.append(_RelationOutcome(rid=str(rel["id"]), target=_relation_target(rel), coverage=coverage,
cardinality=cardinality, holds=holds, sample=len(literals),
matched=matched, note=note))
matched=matched, note=note, cardinality_checked=checked))
# ==========================================================================
@@ -869,7 +876,7 @@ async def _check_codes(cx: _Context, table: str, column: str, out: _TableOutcome
rows = sum(n for _, n in values)
scope = f"按前 {scan_cap} 行统计" if scan_cap else "统计全表"
spread = "、".join(f"{k}({_share(n, rows)})" for k, n in values)
note = f"{NOTE_PREFIX}{cx.day}):{scope},{rows} 行非空值共 {len(values)} 个取值:{spread}。"
note = f"{NOTE_PREFIX}{scope},{rows} 行非空值共 {len(values)} 个取值:{spread}。"
out.codes.append(_CodesOutcome(column=column, values=values, rows=rows, note=note, complete=scan_cap is None))
@@ -899,7 +906,7 @@ async def _check_dates(cx: _Context, table: str, columns: list[str], total: int,
if not result.date_ranges:
raise _Skip("no_data", f"{table}.{columns[0]} 没有可识别的日期值")
found = result.date_ranges[0]
note = (f"{NOTE_PREFIX}{cx.day}):表里只有这一个日期类列,取值从 {str(found['min'])[:19]} 到 "
note = (f"{NOTE_PREFIX}表里只有这一个日期类列,取值从 {str(found['min'])[:19]} 到 "
f"{str(found['max'])[:19]},可作为业务日期的参考。")
out.business_date = _DateOutcome(column=found["column"], low=found["min"], high=found["max"], note=note)
@@ -956,9 +963,11 @@ def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
changed = True
if rel.get("cardinality") is None and o.cardinality is not None:
rel["cardinality"] = o.cardinality
if o.cardinality_checked:
rel["cardinality_checked"] = True
changed = True
note = rel.get("note")
if (not note or str(note).startswith(NOTE_PREFIX)) and not catalog.same_profile_note(note, o.note):
if (not note or catalog.is_profile_note(note)) and not catalog.same_profile_note(note, o.note):
rel["note"] = o.note
changed = True
if changed:
@@ -995,7 +1004,9 @@ def _apply(existing: dict[str, Any] | None, out: _TableOutcome, *,
status = "verified" if o.holds else "proposed"
relations.append({"id": o.rid, "columns": list(rel["columns"]), "to_table": rel["to_table"],
"to_columns": list(rel["to_columns"]), "cardinality": o.cardinality,
"coverage": o.coverage, "source": "profile", "status": status, "note": o.note})
"coverage": o.coverage, "source": "profile", "status": status, "note": o.note,
# 只在基数用数据核实过时有这个键:没核实的关系和以前一字不差
**({"cardinality_checked": True} if o.cardinality_checked else {})})
findings.append(_relation_finding(o, rel, status=status, confirmed=False))
for rid in out.keep_relations:
rel = current.get(rid)
@@ -1152,7 +1163,44 @@ async def _pick_tables(session: AsyncSession, source: Any, tables: list[str] | N
candidates = [name for name, meta in all_tables.items()
if not (meta or {}).get("is_view") and name in entries
and _relation_candidates(entries[name].notes)]
return sorted(candidates, key=lambda n: (-usage.get(n, 0), order[n]))[:PROFILE_DEFAULT_TABLES]
# 还有待核实(推断)关系的表排在前面:剖析就是来核实它们的;关系都核实过、人工确认过的再剖析只是补覆盖率。
# 同样待核实的按使用次数,再按表结构顺序。返回全部候选,空表由 _skip_empty 估过行数再跳过
return sorted(candidates, key=lambda n: (not _pending(entries[n].notes), -usage.get(n, 0), order[n]))
def _pending(notes: dict[str, Any] | None) -> bool:
"""这张表还有没有待核实的关系:要核对的关系里有还是推断(proposed)的。"""
return any(r.get("status") == "proposed" for r in _relation_candidates(notes))
async def _skip_empty(cx: _Context, candidates: list[str], report: ProfileReport) -> list[TableProfile]:
"""不指定表时:按顺序估候选表的行数,确知是空表的跳过(记进 report.empty_tables),凑够 PROFILE_DEFAULT_TABLES
张为止。以前不估,所有表使用次数都是 0 时按表结构顺序取前 10 张,结果大半是空表、一条关系都核实不了。
估行数的查询算进这张表的查询数,行数留在 cx.sizes 里,剖析时不再查。最多估 PROFILE_PROBE_TABLES 张、最多用掉
查询次数上限的一半;超过了、或者整次剖析已经停下,剩下的候选不估、照顺序排上(真是空表的,剖析时各项检查会
记下「空表,无法剖析」)。
"""
picked: list[TableProfile] = []
probed = 0
for name in candidates:
if len(picked) >= PROFILE_DEFAULT_TABLES:
break
result = TableProfile(table=name)
if (cx.run.stopped is None and probed < PROFILE_PROBE_TABLES
and cx.run.used < cx.settings.max_queries // 2):
probed += 1
before = cx.run.used
try:
size = await cx.size(name, result)
except _Stop:
size = None
result.queries = cx.run.used - before
if size is not None and size.rows == 0:
report.empty_tables.append(name)
continue
picked.append(result)
return picked
async def profile_catalog(session: AsyncSession, row: Any, source: Any, *, tables: list[str] | None = None,
@@ -1194,11 +1242,12 @@ async def _profile(session: AsyncSession, row: Any, source: Any, tables: list[st
run = _Runner(source, settings, dialect)
cx = _Context(source=source, settings=settings, dialect=dialect, run=run,
tables=(getattr(source, "schema_cache", None) or {}).get("tables") or {},
masked={c.lower() for c in data_engine.masked_columns(getattr(row, "options", None))},
day=at[:10])
masked={c.lower() for c in data_engine.masked_columns(getattr(row, "options", None))})
entries = await catalog.read_catalog(session, source.id)
for table in await _pick_tables(session, source, tables, entries, report):
result = TableProfile(table=table)
picked = await _pick_tables(session, source, tables, entries, report)
results = [TableProfile(table=t) for t in picked] if tables else await _skip_empty(cx, picked, report)
for result in results:
table = result.table
report.tables.append(result)
if run.stopped:
detail = _STOP_DETAIL[run.stopped].format(max_queries=settings.max_queries,
@@ -1208,7 +1257,7 @@ async def _profile(session: AsyncSession, row: Any, source: Any, tables: list[st
before = run.used
entry = entries.get(table)
out = await _profile_table(cx, table, entry.notes if entry else {}, result)
result.queries = run.used - before
result.queries += run.used - before
await _write(session, source.id, table, out, result, at=at, actor=actor)
report.queries_used = run.used
report.stopped = run.stopped
@@ -1218,6 +1267,6 @@ async def _profile(session: AsyncSession, row: Any, source: Any, tables: list[st
__all__ = ["CODES_MAX", "IN_CHUNK", "MAX_CODE_LEN", "MAX_LITERAL_LEN", "NOTE_PREFIX", "PROFILE_DEFAULT_TABLES",
"PROFILE_OPTION", "ProfileBusy", "ProfileDisabled", "ProfileReport", "ProfileSettings", "ProfileSkip",
"SqlDialect", "TableProfile", "TableSize", "ensure_enabled", "estimate_size", "profile_catalog",
"profile_settings", "profile_settings_problem", "sql_literal"]
"PROFILE_OPTION", "PROFILE_PROBE_TABLES", "ProfileBusy", "ProfileDisabled", "ProfileReport",
"ProfileSettings", "ProfileSkip", "SqlDialect", "TableProfile", "TableSize", "ensure_enabled",
"estimate_size", "profile_catalog", "profile_settings", "profile_settings_problem", "sql_literal"]
+44 -3
View File
@@ -16,7 +16,10 @@
- wrong_date_column:表定义了业务日期,查询却按这张表的另一个时间列分组或筛选。
**级别看依据。** 依据全是 confirmed / verified(人工确认、外键约束、数据剖析)时,fanout_sum、stock_summed 是 error,
其余是 warning;依据里有 proposed(命名推断、模型起草)就一律降为 info。join_unconfirmed 反过来看:对上推断的关系是
其余是 warning;依据里有 proposed(命名推断、模型起草)就一律降为 info。fanout_sum 另有一道门槛:error 只给「一对多
确有其事」的——那条一对多的关系人工确认过,或者数据剖析在数据上核实过子表一侧的键不唯一(关系上的
cardinality_checked)。只凭外键约束推出的一对多降为 warning、说「可能重复计算」:外键只保证父键唯一,子表一侧完全
可能每个键恰好一行,照「会重复计算」报错就是误报,还会让出具降档。join_unconfirmed 反过来看:对上推断的关系是
info,什么都对不上是 warning,对上有确证的关系不报。rejected 的项一律视而不见(catalog.visible_notes),被驳回的关系
既不算「对上了」,也不拿来判断基数。
@@ -50,7 +53,8 @@ CHECK_CODES = ("fanout_sum", "stock_summed", "join_unconfirmed", "ratio_aggregat
"unknown_code", "wrong_date_column")
#: 结果的级别,从重到轻
LEVELS = ("error", "warning", "info")
#: 依据有确证时报 error 的规则:它们算出来的数必然是错的。其余规则只是「很可能不对」
#: 依据有确证时报 error 的规则:照这样写,算出来的数很可能是错的。其余规则只是「可能不对」。
#: fanout_sum 另要那条一对多的基数人工确认过、或用数据核实过(_ScopeCheck.fanout)
_ERROR_CODES = frozenset({"fanout_sum", "stock_summed"})
_STRONG = frozenset({"confirmed", "verified"})
_STATUS_RANK = {"confirmed": 3, "verified": 2, "proposed": 1}
@@ -221,6 +225,25 @@ def check_sql(sql: Any, *, kind: str | None, schema_cache: Mapping[str, Any] | N
return SqlChecker(kind=kind, schema_cache=schema_cache, notes=notes, source_name=source_name).check(sql)
def frozen_checker(schema_snapshot: Any, *, kind: str | None) -> SqlChecker | None:
"""按表结构快照里冻结的那一版目录重建检查器:事后核对一次运行当时的 SQL 检查用。快照里没冻结目录返回 None。
数据源查询工具每次查询都把当时的表结构和目录冻结进表结构快照(tools/datasource._store_schema,
catalog.frozen_catalog)。冻结的目录去掉了驳回项,只留被驳回关系的编号和状态,关系图据此不把它们按外键推回来。
快照里没有数据源类型,方言由调用方给(数据源的 kind)。
"""
if not isinstance(schema_snapshot, Mapping):
return None
frozen = schema_snapshot.get("catalog")
tables = schema_snapshot.get("tables")
if not isinstance(frozen, Mapping) or not frozen or not isinstance(tables, Mapping):
return None
notes = {str(name): entry.get("notes") for name, entry in frozen.items()
if isinstance(entry, Mapping) and isinstance(entry.get("notes"), Mapping)}
cache = {"tables": tables, **({"schema": schema_snapshot["schema"]} if schema_snapshot.get("schema") else {})}
return SqlChecker(kind=kind, schema_cache=cache, notes=notes, source_name=schema_snapshot.get("source"))
def _fold(found: list[SqlCheck]) -> list[SqlCheck]:
"""同一件事只报一次(规则、表、列、关系相同的留最重的那条),重的在前,最多 MAX_CHECKS 条。"""
kept: dict[tuple[Any, ...], SqlCheck] = {}
@@ -681,6 +704,12 @@ class _ScopeCheck:
[status] + [e.status for e in path], counting=isinstance(node, exp.Count)))
return out
@staticmethod
def _one_to_many_established(edge: catalog.JoinEdge) -> bool:
"""这条一对多是不是确有其事:人工确认过(基数是人定的),或者数据剖析在数据上核实过子表一侧的键不唯一
(cardinality_checked)。外键约束、命名推断给的基数只是按表结构推的:子表一侧可能每个键恰好一行。"""
return edge.status == "confirmed" or edge.cardinality_checked
def _fanout_basis(self, table: _Table, columns: list[str], *, counting: bool) -> tuple[str, str] | None:
"""参数里能说明「这是一张表自己的数」的那一列和它的依据:求和看度量类型(流量、存量),
计数另外认标识列和业务主键(数订单数却数成了明细行数)。"""
@@ -698,6 +727,18 @@ class _ScopeCheck:
path: list[catalog.JoinEdge], statuses: list[str], *, counting: bool) -> SqlCheck:
what = "计数" if counting else "求和"
excerpt = self.excerpt(node)
level = _level("fanout_sum", statuses)
if not self._one_to_many_established(path[-1]):
# 只凭表结构推出的一对多:不说「会重复计算」,最多是提醒;下一步先确认是不是真的一对多
return SqlCheck(
code="fanout_sum", level="warning" if level == "error" else level, table=table.name, column=column,
relation_id=path[-1].relation_id, sql_excerpt=excerpt,
message=(f"按表结构,「{via.label()}」关联「{many.label()}」是一对多,"
f"对「{table.label()}」的「{table.column_label(column)}」{what}可能重复计算。"
f"请确认关联表「{many.label()}」里每个键只有一行,或先汇总再关联"),
for_model=(f"按表结构,{via.name} 关联 {many.name} 是一对多(这个基数没用数据核对过),"
f"{excerpt or what} 可能把 {table.name}.{column} 重复计算。确认 {many.name} 里每个关联键"
f"只有一行;不确定就先在子查询里把 {many.name} 汇总成每条 {via.name} 一行再关联"))
if via.alias == table.alias:
head = f"「{table.label()}」关联「{many.label()}」是一对多,"
model_head = f"{table.name} 关联 {many.name} 是一对多(一行 {table.name} 对多行 {many.name}),"
@@ -721,7 +762,7 @@ class _ScopeCheck:
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,
code="fanout_sum", level=level, 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}")
+53 -1
View File
@@ -2631,7 +2631,8 @@ def compose_doc(
"markdown": markdown, "source": source, "catalog": kept, "blocks": out_blocks,
}
checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps)
doc["stats"], doc["violations"] = checked["stats"], checked["violations"]
# 句中的 [[see:]] 只在组装时查(交回写作者重写):出口复核看的是数字和出处,句子通不通不影响出具
doc["stats"], doc["violations"] = checked["stats"], checked["violations"] + inline_see(source)
return doc
@@ -2999,6 +3000,38 @@ MARKER_RULES = """写作规则(系统会逐字核对):
4. 日期、ISO 周(2026-W37)、「前 3 名」「第 2 季度」这类序号可以直接写。
5. 目录里没有的指标不要编造引用;标着「没有值」的指标不要写进报告。"""
#: 跟在写作规则后面的两条(R2 走查 A4、B4):写作规则本身保持一期那份,这两条另起一段
#: - [[see:]] 渲染后不显示:写进句子中间(「以 [[see:Q1]] 为依据看」)会留下「以 为依据看」这样的断句;
#: - 码值(status = 9 里的 9)是没有出处的数字,会被数字核对打回重写:写它的含义,不写码值本身。
SEE_AND_CODE_RULES = (
"另外两条:\n"
"- [[see:…]] 渲染后不显示,只能挂在句末(句号前后都行)。不要把它写进句子当成分:「以 [[see:Q1]] 为依据看,……」"
"「根据 [[see:Q1]],……」渲染后会变成「以 为依据看」「根据 ,」这样的断句。先说结论,句末再写 [[see:Q1]]。\n"
"- 涉及码值(状态、类型这类用数字编码的取值)时,写它的含义,比如「作废记录」「有效记录」,不要写码值本身"
"(「状态为 9」「类型 1」):码值是没有出处的数字,系统会当成裸数字打回重写。"
)
#: 句中的 [[see:]]:前面是介词、后面接着句子的写法(「以 [[see:Q1]] 为依据」「根据 [[see:Q1]],」「详见 [[see:Q1]]」)。
#: 只认这几种拿得准的:「以」前面是「所、可、加……」的是另一个词(所以、可以),不算;「依据 [[see:…]]。」可能是
#: 「这是结论的依据」,不算。认出来就交回写作者重写(report.REWRITE_ONLY:重写不好也不判失败)
_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
_INLINE_SEE = re.compile(
r"(?<![所可加予得难足用是何之])以[ \t\u3000]*" + _SEE_MARK + r"[ \t\u3000]*为"
r"|(?:根据|按照|基于|参见|详见)[ \t\u3000]*" + _SEE_MARK
)
INLINE_SEE_MESSAGE = _Reason(
"依据标记写在了句子中间:依据不显示,正文会留下「以 为依据」这样的断句",
"依据标记 [[see:…]] 写在了句子中间。它渲染后不显示,只能挂在句末:把这一句改成先说结论、句末再写 [[see:…]],"
"不要写「以 [[see:Q1]] 为依据」「根据 [[see:Q1]]」「详见 [[see:Q1]]」",
)
def inline_see(source: str) -> list[dict[str, Any]]:
"""原文里写在句子中间的 [[see:]](_INLINE_SEE),每处一条违规 inline_see。text 是那一截原文,没有 span:
标记渲染后不在正文里,定位不到片段。"""
return [_violation("inline_see", INLINE_SEE_MESSAGE, text=m.group(0)) for m in _INLINE_SEE.finditer(source or "")]
#: 引用查询结果的写法。只跟着目录里的查询出现(catalog_prompt):写作规则本身保持一期那份,升级前
#: 发起的运行跑到报告节点时提示一字不差;只有口径卡的目录也不该教写作者去引用不存在的 Q1
CELL_RULES = (
@@ -3044,6 +3077,23 @@ _PROMPT_HITS = 8
_PROMPT_HIT_CHARS = 300
def _sql_check_note(artifact: Any, snaps: _Snapshots) -> str | None:
"""这次查询(合并查询则是它的输入)没通过 SQL 检查时,写作目录里跟在它后面的那句提醒;没问题返回 None。
口径卡的指标早就标「存疑」了,直接引用查询格子的写作者却看不到:报告照样把一对多关联后求和的数写成定论。
出具契约会按缺口降档(issuance.sql_check_gaps),这里先让写作者知道、引用时如实交代。
"""
from app.engine.sql_problems import query_problems
found = query_problems(artifact, loader=snaps.loader)
if not found:
return None
problems = list(dict.fromkeys(text for p in found for text in p.problems))
who = "这次查询" if all(p.artifact == artifact for p in found) else "这次合并的输入查询"
return (f" ({who}未通过 SQL 检查:{';'.join(problems[:3]) or '有错误级的问题'}。其中的数存疑:"
"引用时须如实说明,不要当作确定的结论)")
def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool) -> list[str]:
"""一次查询在写作目录里的样子:编号、来源、列,以及能引用的行(行号 + 渲染后的值)。
@@ -3058,6 +3108,8 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool)
if snapshot.get("truncated") is True:
lines.append(" (查询结果已截断:只取回了前面一部分行,库里还有更多。单格可以引用;"
"不要把取回的行数当成总数,也不要据此写合计、全部之类的结论)")
if note := _sql_check_note(entry.get("artifact"), snaps):
lines.append(note)
if not cells_allowed:
return lines
columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS]
+61 -7
View File
@@ -4,6 +4,8 @@ import re
from dataclasses import dataclass, field
from typing import Any, Callable
from app.engine.sql_problems import problem_clause, query_problems
# --------------------------------------------------------------------------
# 出具校验:叙述里的每个数字必须能回指到指标集
#
@@ -462,15 +464,67 @@ 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 级问题,比如一对多关联之后对「一」那一侧求和),每个一句缺口。
def sql_check_gaps(metrics: list[dict[str, Any]], cited: dict[str, list[str]] | None = None, *,
label: Callable[[str, str | None], str | None] | None = None) -> list[str]:
"""sql_check_findings 的那几句缺口(见下)。"""
return [f["text"] for f in sql_check_findings(metrics, cited, label=label)]
和 incomplete_gaps 同一个道理:数字照样回指得上口径卡,口径卡照样复算一致,但算它的那条查询本身就把数算错了
(重复计算、存量跨期加总),盖完整出具的章等于替一个错数作保。原因用口径卡记下的那句,写明是哪条检查。
def sql_check_findings(metrics: list[dict[str, Any]], cited: dict[str, list[str]] | None = None, *,
label: Callable[[str, str | None], str | None] | None = None) -> list[dict[str, Any]]:
"""没通过 SQL 检查(error 级:一对多关联后重复计算、存量跨期加总)的查询,每条一句缺口。
两处来历:契约收来的指标(口径卡标了 sql_check_failed,sql_check_sources 记着取数的查询快照),以及报告直接
引用的查询(cited:{查询快照 id: [引用的写法…]},单元格、表格、[[see:]] 依据)。数字照样回指得上,口径卡照样
复算一致,但算它的那条查询本身就把数算错了,盖完整出具的章等于替一个错数作保。
按出问题的那条查询归并(合并查询顺着输入找到源查询):同一条查询算出三个指标、报告又引用了它的格,也只记一句,
句子里列出受影响的引用。label(快照 id, 节点 id) 给出「查询「取数」(Q1)」这样的称呼,给不出就写「所依据的查询」。
指标标了 sql_check_failed、快照却读不出来(被清理、被改过)的,退回口径卡记下的那句:缺口不能因为读不到快照就消失。
每条一个 dict:text(缺口那句)、query(称呼)、artifact、node_id、problems、refs。出具声明里另存一份
sql_checks:报告页据此在显眼处说「因为 SQL 检查没通过而降档」,不用从缺口的中文里认。
"""
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")]
groups: dict[str, dict[str, Any]] = {}
fallback: list[dict[str, Any]] = []
def add(artifact: Any, ref: str) -> bool:
found = query_problems(artifact) if isinstance(artifact, str) and artifact else []
for problem in found:
group = groups.setdefault(problem.artifact, {"problem": problem, "refs": []})
if group["problem"].node_id is None and problem.node_id:
group["problem"].node_id = problem.node_id
if ref not in group["refs"]:
group["refs"].append(ref)
return bool(found)
for m in metrics:
if not (isinstance(m, dict) and m.get("sql_check_failed")):
continue
name = f"指标「{m.get('name') or m.get('id')}」"
hits = [add(src.get("artifact"), name) for src in m.get("sql_check_sources") or [] if isinstance(src, dict)]
if not any(hits):
fallback.append({"text": f"{name}{m.get('sql_check_reason') or '所依据的查询未通过 SQL 检查'}",
"query": None, "artifact": None, "node_id": None, "problems": [], "refs": [name]})
for artifact, refs in (cited or {}).items():
for ref in refs:
add(artifact, ref)
out = []
for artifact, group in groups.items():
problem = group["problem"]
named = label(artifact, problem.node_id) if label else None
who = named or "所依据的查询"
refs = group["refs"]
listed = "、".join(refs[:_SQL_REFS]) + (f"等 {len(refs)} 处" if len(refs) > _SQL_REFS else "")
out.append({"text": f"{who}{problem_clause(problem.problems)};受影响的引用:{listed}", "query": named,
"artifact": artifact, "node_id": problem.node_id, "problems": problem.problems,
"refs": refs[:_SQL_REFS * 2]})
return out + fallback
#: 一句 SQL 检查缺口里最多列出几处受影响的引用(计数照实)
_SQL_REFS = 8
def _claims_effect(
+5 -3
View File
@@ -834,7 +834,8 @@ def _warnings(stmt: str, inputs: list[MergeInput], tables: dict[str, list[str]],
else:
tail = ("SQLite 比较时会做隐式转换:文本形式的编号(如 '001'、'01')都会等于数值 1,也可能完全匹配不上。"
"请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换")
out.append({"code": "key_type_mismatch", "keys": [f"{a}.{ac}", f"{b}.{bc}"],
# keys、detail 是给界面读的结构化字段(运行时间线的标题、副标题),不让它从 message 里解析中文
out.append({"code": "key_type_mismatch", "keys": [f"{a}.{ac}", f"{b}.{bc}"], "detail": f"{left},{right}",
"message": f"合并键类型不一致:{left},{right}。{tail}"})
largest = max(inputs, key=lambda inp: len(inp.snapshot["rows"]))
biggest = len(largest.snapshot["rows"])
@@ -846,12 +847,13 @@ def _warnings(stmt: str, inputs: list[MergeInput], tables: dict[str, list[str]],
keys[alias].append(col)
dups = [_duplicates(alias, snaps[alias], cols) for alias, cols in keys.items()]
detail = ";".join(d for d in dups if d)
out.append({"code": "rows_grew", "message": (
out.append({"code": "rows_grew", "rows": len(rows), "input": largest.alias, "input_rows": biggest,
**({"duplicates": detail} if detail else {}), "message": (
f"合并结果有 {len(rows)} 行,多于行数最多的输入「{largest.alias}」({biggest} 行):合并键可能不唯一,"
"同一行被重复匹配。" + (f"{detail}。" if detail else "")
+ "请检查合并条件是否覆盖了全部键(例如同时按日期和门店),或先在源库里聚合到相同粒度")})
if truncated:
out.append({"code": "result_truncated", "message": (
out.append({"code": "result_truncated", "rows": len(rows), "max_rows": limits.max_rows, "message": (
f"合并结果超过上限({limits.max_rows} 行或约 {max(1, limits.max_bytes // 1_000_000)} MB),只保留了前 "
f"{len(rows)} 行,下游拿到的不是完整结果。请在合并 SQL 中聚合或加条件缩小范围")})
return out
+79 -7
View File
@@ -147,7 +147,7 @@ def _apply_contract(
) -> dict[str, Any]:
from datetime import datetime, timezone
from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_gaps, trace_numbers
from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_findings, trace_numbers
nodes = state.get("nodes") or {}
# 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述
@@ -188,8 +188,9 @@ def _apply_contract(
gaps.append(f"指标来源未解析:{'、'.join(unresolved[:5])}")
# 拿截断的查询结果整组算出来的指标:数回指得上,但算的只是取回的那一截,不能完整出具
gaps.extend(incomplete_gaps(metrics))
# 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总)的指标:口径卡复算得一致,算的却是错数
gaps.extend(sql_check_gaps(metrics))
# 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总):口径卡复算得一致,算的却是错数。
# 报告直接引用的查询要等引用核对之后才知道,缺口放在这个位置,下面一起算
sql_at = len(gaps)
# 引用模式下,缺输入记成空值的指标(口径卡 on_missing=null)就是缺:报告里引用不了它,
# 也不能因为 id 在清单里就算「齐了」。旧模式照旧只看 id,行为不变
@@ -222,13 +223,37 @@ def _apply_contract(
)
unmatched = trace.unmatched if trace else []
# 同一条查询算出几个指标、报告又引用了它的格,只记一句(按出问题的那条查询归并,列出受影响的引用)
titles = {n.id: n.title for n in ctx.run.spec.nodes}
aliases: dict[str, str] = cited.get("aliases") or {}
def query_label(artifact: str, node_id: str | None) -> str:
from app.engine.sql_problems import node_of
title = titles.get(node_id or node_of(artifact, state.get("evidence")) or "")
alias = aliases.get(artifact)
if title and alias:
return f"查询「{title}」({alias})"
return f"查询「{title}」" if title else (f"查询 {alias}" if alias else "")
findings = sql_check_findings(metrics, cited.get("queries"), label=query_label)
gaps[sql_at:sql_at] = [f["text"] for f in findings]
# 出具声明另存一份结构化的:报告页在显眼处说「因为 SQL 检查没通过而降档」,不从缺口的中文里认。没有就不加这个键
from app.engine.sql_problems import node_of
for f in findings:
if not f.get("node_id") and f.get("artifact"):
f["node_id"] = node_of(f["artifact"], state.get("evidence"))
# gap 是 gaps 里对应的那一句:界面单列这几条时,据它不在「其余缺口」里重复
sql_checks = [{**{k: f[k] for k in ("query", "node_id", "problems", "refs") if f.get(k)}, "gap": f["text"]}
for f in findings]
# 声明了 metrics_from 却一个指标都没收到,同样是"没查成"
if sources and not metrics:
gaps.append("指标集为空,叙述中的数字无法追溯")
# 上游有协作团队用完轮数、按降档交付的:叙述里的话不是调度者认可的结论,
# 数字全都对得上也不能盖「完整出具」
titles = {n.id: n.title for n in ctx.run.spec.nodes}
for node_id, payload in nodes.items():
if isinstance(payload, dict) and payload.get("exhausted"):
gaps.append(f"协作团队「{titles.get(node_id, node_id)}」用完 {payload.get('rounds', '?')} "
@@ -250,7 +275,7 @@ def _apply_contract(
return _declare_citations(tier, cited, calibers=calibers, metrics=metrics, gaps=gaps,
missing_required=missing_required, missing_expected=missing_expected,
report_from=str(report_from), declared_at=datetime.now(timezone.utc),
ctx=ctx)
ctx=ctx, sql_checks=sql_checks)
# 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」
matched = [
@@ -269,6 +294,7 @@ def _apply_contract(
# 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查"
"gaps": gaps,
"declared_at": datetime.now(timezone.utc).isoformat(),
**({"sql_checks": sql_checks} if sql_checks else {}),
}
ctx.emit(
EventType.ISSUANCE,
@@ -283,6 +309,7 @@ def _apply_contract(
metrics_checked=len(metrics),
matched_numbers=len(trace.matched) if trace else 0,
matched=matched,
**({"sql_checks": sql_checks} if sql_checks else {}),
)
return issuance
@@ -318,7 +345,7 @@ def _check_citations(
out: dict[str, Any] = {"matched": [], "unmatched": [], "unresolved": [], "gaps": [],
"doc_artifact": None, "stats": None, "claims_policy": None, "uncited": None,
"unsupported": None, "claims": None, "entities": None}
"unsupported": None, "claims": None, "entities": None, "queries": {}, "aliases": {}}
gaps = out["gaps"]
spec = ctx.run.spec
node = spec.node_map().get(report_from)
@@ -384,6 +411,8 @@ def _check_citations(
out["entities"] = {"unknown": unknown[:_LISTED], "unverified": unverified[:_LISTED],
"counted": governed and bool(unknown)}
out["queries"], out["aliases"] = _cited_queries(doc, catalog)
# 老前端要用的逐个数字出处,从复核通过的数字片段拼出来
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
@@ -524,10 +553,50 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An
}
def _cited_queries(doc: dict[str, Any], catalog: dict[str, Any]) -> tuple[dict[str, list[str]], dict[str, str]]:
"""报告直接引用的查询:({查询快照 id: [引用的写法…]}, {目录里每个查询的快照 id: 编号})。
单元格([[v:Q1.r0.gmv]],[[table:]] 展开后也是一格一格的引用)按查询数:一格写「Q1 第 1 行「gmv」」,
几格写「Q1 的 3 格」;只拿它当依据([[see:Q1]])的写「Q1(结论依据)」。出具契约据此判这些查询有没有通过
SQL 检查(issuance.sql_check_gaps)。
"""
from app.engine.evidence import iter_units
cells: dict[str, list[tuple[Any, Any]]] = {}
support: list[str] = []
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") or {}
if cite.get("kind") == "cell" and cite.get("status") == "resolved" and cite.get("alias"):
loc = cite.get("locator") or {}
cells.setdefault(str(cite["alias"]), [])
if (loc.get("row"), loc.get("column")) not in cells[str(cite["alias"])]:
cells[str(cite["alias"])].append((loc.get("row"), loc.get("column")))
for cite in unit.get("see") or []:
alias = str((cite or {}).get("alias") or "")
if (catalog.get(alias) or {}).get("kind") == "query" and alias not in support:
support.append(alias)
queries: dict[str, list[str]] = {}
# 目录里全部查询的编号:合并查询的输入没被直接引用,缺口里也要写得出它是 Q 几
aliases = {str(e["artifact"]): str(e["alias"]) for e in catalog.values()
if isinstance(e, dict) and e.get("kind") == "query" and e.get("artifact") and e.get("alias")}
for alias in [*cells, *(a for a in support if a not in cells)]:
artifact = (catalog.get(alias) or {}).get("artifact")
if not isinstance(artifact, str) or not artifact:
continue
hit = cells.get(alias) or []
if len(hit) == 1 and isinstance(hit[0][0], int):
ref = f"{alias} 第 {hit[0][0] + 1} 行「{hit[0][1]}」"
else:
ref = f"{alias} 的 {len(hit)} 格" if hit else f"{alias}(结论依据)"
queries.setdefault(artifact, []).append(ref)
return queries, aliases
def _declare_citations(
tier: str, cited: dict[str, Any], *, calibers: list[dict[str, str]], metrics: list[dict[str, Any]],
gaps: list[str], missing_required: list[str], missing_expected: list[str], report_from: str,
declared_at: Any, ctx: NodeContext,
declared_at: Any, ctx: NodeContext, sql_checks: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。"""
report = {"node_id": report_from, "doc_artifact": cited["doc_artifact"]}
@@ -549,6 +618,8 @@ def _declare_citations(
# 结论句策略和可疑实体:只在用上时才有这两个键,升级前的运行和没用上的契约形状不变
**({"claims": cited["claims"]} if cited.get("claims") else {}),
**({"entities": cited["entities"]} if cited.get("entities") else {}),
# 没通过 SQL 检查的查询(结构化):报告页据此在显眼处说明降档原因。没有就不加这个键
**({"sql_checks": sql_checks} if sql_checks else {}),
}
entities = cited.get("entities")
ctx.emit(
@@ -562,6 +633,7 @@ def _declare_citations(
unresolved=len(cited["unresolved"]),
calibers=calibers,
gaps=gaps,
**({"sql_checks": sql_checks} if sql_checks else {}),
metrics_checked=len(metrics),
matched_numbers=len(cited["matched"]),
matched=cited["matched"][:50],
+3
View File
@@ -47,6 +47,7 @@ from app.providers.factory import (
bind_tools_safely,
get_chat_model,
)
from app.engine.sql_problems import event_checks
from app.tools.datasource import QUERY_PREFIX, run_versions
from app.tools.registry import (
ToolArgsError,
@@ -518,6 +519,8 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
**({"query_artifact": query["artifact"]} if query else {}),
# 查询当时数据源的表结构快照:和查询快照一样落在封存范围内,证据接口从这里认它
**({"schema_artifact": query["schema_artifact"]} if query and query.get("schema_artifact") else {}),
# 这次查询的 SQL 检查结果单独带上:preview 截到前 2000 个字符,checks 通常在截掉的那段里
**({"checks": checks} if ok and name.startswith(QUERY_PREFIX) and (checks := event_checks(content)) else {}),
**extra,
)
outcome: dict[str, Any] = {"content": content, "ok": ok, "duration_ms": elapsed}
+3 -1
View File
@@ -108,8 +108,10 @@ async def run_merge(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
preview_rows=outcome.rows[:PREVIEW_ROWS], duration_ms=elapsed, query_artifact=artifact,
lineage=outcome.lineage is not None)
for warning in outcome.warnings:
# 结构化字段(keys、detail、rows、input、input_rows、duplicates、max_rows)随日志带上:界面读字段,不解析中文
fields = {k: v for k, v in warning.items() if k not in ("code", "message")}
ctx.emit(EventType.LOG, level="warn", message=warning["message"],
code=LOG_CODES.get(warning["code"], "merge_warning"))
code=LOG_CODES.get(warning["code"], "merge_warning"), **fields)
output: dict[str, Any] = {
"artifact": artifact, "columns": outcome.columns, "rows": outcome.rows, "row_count": len(outcome.rows),
+56 -43
View File
@@ -33,7 +33,7 @@ from app.engine.expressions import (
substitute,
)
from app.engine.labels import field_label
from app.engine.merge_query import MERGE_SOURCE
from app.engine.sql_problems import CHECK_FIELDS, QueryProblem, merge_problems, node_of, query_problems, reason_text
from app.engine.state import GraphState, template_context
@@ -75,8 +75,11 @@ 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]]] = {}
#: 查询快照工件 id → 它(含合并查询的输入)没通过 SQL 检查的那几条查询。一张卡里多个指标常常取自同一次查询,
#: 快照只读一次
checked: dict[str, list[QueryProblem]] = {}
#: 指标 id → 它没通过 SQL 检查的那几条来源查询。运行日志按查询归并(_emit_sql_check_logs)
failed_by: dict[str, list[QueryProblem]] = {}
for definition in definitions:
metric_id = str(definition.get("id") or "").strip()
expr = str(definition.get("expression") or "").strip()
@@ -138,7 +141,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> 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]}
code for p in found for code in p.codes))} for artifact, found in failed]}
failed_by[metric_id] = [p for _, found in failed for p in found]
metrics.append(
{
"id": metric_id,
@@ -177,11 +181,11 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
]
for m in metrics:
if m.get("incomplete"):
ctx.emit(EventType.LOG, level="warn", code="metric_incomplete", metric=m["id"],
# 结构化字段(metric_name、reason、rows)给界面读,不让它从 message 里解析中文
ctx.emit(EventType.LOG, level="warn", code="metric_incomplete", metric=m["id"], metric_name=m["name"],
reason=m["incomplete_reason"], rows=[s.get("rows") for s in m.get("truncated_sources") or []],
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']}")
_emit_sql_check_logs(metrics, failed_by, state, ctx)
card = {
"kind": "metric_set",
"caliber": caliber,
@@ -354,9 +358,10 @@ _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 级检查结果)]。
tctx: dict[str, Any], checked: dict[str, list[QueryProblem]],
) -> list[tuple[str, list[QueryProblem]]]:
"""这个指标用到的查询里,SQL 检查有 error 的:[(查询快照工件 id, 没通过检查的查询)]。合并查询的快照对应它出问题的
那几个输入(sql_problems.query_problems)。
用到了哪些查询,三处来历合起来认:输入的出处(cell() 取的格、Agent 经 cite_fields 交来的字段都带着快照 id)、
截断跟踪记下的整组、取值链上的查询结果本身(数据整形解析过的 vars.res.rows[0][0],往上找到 vars.res 那份
@@ -368,7 +373,7 @@ def _failed_queries(inputs: list[dict[str, Any]], truncated: list[dict[str, Any]
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)
checked[artifact] = query_problems(artifact)
if checked[artifact]:
out.append((artifact, checked[artifact]))
return out
@@ -394,41 +399,49 @@ def _result_artifact(path: str, tctx: dict[str, Any]) -> str | None:
return None
def _error_checks(artifact: str, _seen: set[str] | None = None) -> list[dict[str, Any]]:
"""这份查询快照对照数据目录查出的 error 级问题。
合并查询的快照没有自己的 checks(它在内存 SQLite 上执行,不对照数据目录),但记着 inputs:顺着往下找。
不然源库那条查询一对多关联后重复计算了,经过一次合并,指标就看不出来、照样完整出具。
"""
seen = _seen if _seen is not None else set()
if artifact in seen:
return []
seen.add(artifact)
try:
snapshot = artifact_store.load(artifact)
except Exception: # noqa: BLE001 - 快照读不出来、被改过:这里不报,证据层另有说法
return []
if not isinstance(snapshot, dict):
return []
checks = snapshot.get("checks")
found = [c for c in checks if isinstance(c, dict) and c.get("level") == "error"] if isinstance(checks, list) else []
inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE else None
for entry in inputs if isinstance(inputs, list) else []:
if isinstance(entry, dict) and isinstance(entry.get("artifact"), str):
found += _error_checks(entry["artifact"], seen)
return found
def _error_checks(artifact: str) -> list[dict[str, Any]]:
"""这份查询快照对照数据目录查出的 error 级问题(合并查询顺着 inputs 往下找,见 sql_problems.query_problems)。"""
return [c for p in query_problems(artifact) for c in p.checks]
def _sql_check_reason(failed: list[tuple[str, list[dict[str, Any]]]]) -> str:
def _sql_check_reason(failed: list[tuple[str, list[QueryProblem]]]) -> 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]}等),结果不可靠"
return reason_text(list(dict.fromkeys(text for _, found in failed for p in found for text in p.problems)))
def _emit_sql_check_logs(metrics: list[dict[str, Any]], failed_by: dict[str, list[QueryProblem]],
state: GraphState, ctx: NodeContext) -> None:
"""没通过 SQL 检查的来源查询,一条一行运行日志(metric_sql_check),列出受它影响的指标。
以前一个指标一行:同一条查询算出三个指标,时间线上同一句话重复三遍。现在按出问题的那条查询归并。
结构化字段给界面读,不让它从 message 里解析中文:metrics / metric_names(受影响的指标)、reason、problems、
checks(不带 for_model)、query_artifact(出问题的查询快照)、source_node(跑这条查询的节点)、
source_field(要改的那一栏:调用工具节点写死的 SQL 在 args.sql;Agent 自己写的 SQL 没有可定位的栏,不给)。
界面据 source_node / source_field 把「打开设置」落到来源查询节点的 SQL 上,而不是口径卡。
"""
names = {m["id"]: m["name"] for m in metrics}
groups: dict[str, tuple[QueryProblem, list[str]]] = {}
for metric_id, found in failed_by.items():
for problem in merge_problems(found):
held = groups.setdefault(problem.artifact, (problem, []))
if metric_id not in held[1]:
held[1].append(metric_id)
nodes = ctx.run.spec.node_map()
for artifact, (problem, ids) in groups.items():
source = problem.node_id or node_of(artifact, state.get("evidence"))
producer = nodes.get(source) if source else None
sql_in_args = producer is not None and str(producer.type) == "tool" \
and isinstance((producer.config.get("args") or {}), dict) and "sql" in (producer.config.get("args") or {})
reason = reason_text(problem.problems)
ctx.emit(EventType.LOG, level="warn", code="metric_sql_check",
metric=ids[0], metrics=ids, metric_names=[names.get(i, i) for i in ids],
reason=reason, problems=problem.problems,
checks=[{k: c[k] for k in CHECK_FIELDS if k in c} for c in problem.checks],
query_artifact=artifact,
**({"source_node": source} if source else {}),
**({"source_field": "args.sql"} if sql_in_args else {}),
message="指标" + "".join(f"「{names.get(i, i)}」" for i in ids) + reason)
def _truncated_field(path: str, state: GraphState, ctx: NodeContext) -> dict[str, Any] | None:
+6 -2
View File
@@ -27,7 +27,8 @@ from app.engine.toolcalls import (
)
from app.providers import catalog
from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
from app.tools.datasource import run_versions
from app.engine.sql_problems import event_checks
from app.tools.datasource import QUERY_PREFIX, run_versions
from app.tools.registry import (
ToolArgsError,
ToolContext,
@@ -419,9 +420,12 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
except Exception as e: # noqa: BLE001
content = f"工具失败:{describe_exception(e)}"
failure = {"error": content, "detail": raw_detail(e)}
# 查库的 SQL 检查结果单独带上:preview 截到前 1500 个字符,checks 通常在截掉的那段里
checks = event_checks(content) if not failure and tname.startswith(QUERY_PREFIX) else []
ctx.emit(EventType.TOOL_ERROR if failure else EventType.TOOL_END, tool=tname,
agent=name, call_id=cid, preview=content[:1500],
duration_ms=int((time.perf_counter() - started) * 1000), **failure)
duration_ms=int((time.perf_counter() - started) * 1000), **failure,
**({"checks": checks} if checks else {}))
calls.append({"tool": tname, "args": targs, "result": content[:2000]})
messages.append(ToolMessage(content=content, tool_call_id=cid))
+13 -4
View File
@@ -48,6 +48,7 @@ from app.engine.evidence import (
CLAIMS_RULE,
ENTITY_KINDS,
MARKER_RULES,
SEE_AND_CODE_RULES,
StreamRenderer,
build_catalog,
catalog_prompt,
@@ -80,6 +81,8 @@ CLAIMS = CLAIMS_VALUES
#: 只标注、不让写作者重写也不判失败的违规:可疑实体(受管的正式出具按缺口降档)、表结构快照不全时
#: 核对不了的名字(出口只标注)
SOFT = frozenset({"unknown_entity", "unverified_entity"})
#: 交回写作者重写、重写不好也不判失败的违规:句中的 [[see:]](渲染后留下断句,数字和出处都没问题)
REWRITE_ONLY = frozenset({"inline_see"})
#: 重写一次就是多一次整篇的模型调用,写不对的模型多给几次也大多写不对
MAX_REPAIRS = 3
@@ -304,6 +307,11 @@ def _blocking(violations: list[dict[str, Any]], numbers: str) -> list[dict[str,
and not (numbers == "off" and v.get("code") == "uncited_number")]
def _fatal(blocking: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""要判失败的违规:重写之后还在的、不属于 REWRITE_ONLY 的。"""
return [v for v in blocking if v.get("code") not in REWRITE_ONLY]
def _violation_key(violation: dict[str, Any]) -> tuple[str, str]:
"""认一处违规:(code, 点名的字)。点名的字按 text、ref、message 的顺序取第一个有的。"""
named = violation.get("text") or violation.get("ref") or violation.get("message") or ""
@@ -348,7 +356,7 @@ def _messages(ctx: NodeContext, state: GraphState, catalog: dict[str, Any], cell
system = ctx.render_str(ctx.cfg("system", ""), state)
instructions = ctx.render_str(ctx.cfg("instructions", ""), state).strip() \
or "根据下面的证据写一份简洁的报告,先总后分。"
rules = MARKER_RULES
rules = f"{MARKER_RULES}\n{SEE_AND_CODE_RULES}"
if claims in ("require_citation", CLAIMS_JUDGE):
# 裁判只看写作者挂的依据:要判,就更得每句都挂上
rules = f"{rules}\n{CLAIMS_RULE}" + (f"\n{judging.JUDGE_RULE}" if claims == CLAIMS_JUDGE else "")
@@ -451,7 +459,7 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
# 探索运行默认按需裁判:点开哪句才判哪句,节点里不花这笔钱
judgement = judging.on_demand(doc, on_unsupported=judge_cfg["on_unsupported"],
rewrite_once=judge_cfg["rewrite_once"], writer_model=model_id)
elif not (blocking and on_violation == "fail"):
elif not (_fatal(blocking) and on_violation == "fail"):
# 反正要判失败的报告不值得再花钱判(节点紧接着就报错)
doc, response, blocking, judgement, usages = await _judge_inline(
ctx, doc, catalog, judge_cfg, model_id=model_id, messages=[*messages, response],
@@ -461,10 +469,11 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
doc_artifact = await _store(doc, ctx)
ctx.emit(EventType.REPORT_CHECKED, doc_artifact=doc_artifact, ok=not doc["violations"],
stats=doc["stats"], violations=doc["violations"][:20], repairs=repairs,
on_violation=on_violation, failed=bool(blocking) and on_violation == "fail",
on_violation=on_violation, failed=bool(_fatal(blocking)) and on_violation == "fail",
**({"claims": claims} if evidence_on else {}),
**({"judge": judgement} if judgement is not None else {}))
if blocking and on_violation == "fail":
if (fatal := _fatal(blocking)) and on_violation == "fail":
blocking = fatal
head = ";".join(v["message"] for v in blocking[:5])
more = f";…另有 {len(blocking) - 5} 处" if len(blocking) > 5 else ""
raise NodeError(
+4 -1
View File
@@ -18,6 +18,7 @@ from app.engine.state import GraphState
from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded
from app.sandbox.base import SandboxLimits
from app.sandbox.manager import sandbox_manager
from app.engine.sql_problems import event_checks
from app.tools.datasource import DATA_UNAVAILABLE, QUERY_PREFIX, SCHEMA_PREFIX, run_versions
from app.tools.registry import (
ToolArgsError,
@@ -184,10 +185,12 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
# 只认数据源工具交回的查询:别的工具(MCP、自定义)拼出同样形状的 JSON,里面的工件 id 不是我们落的
query = query_entry_fields(result) if evidence_on and name.startswith(QUERY_PREFIX) else None
schema = query.get("schema_artifact") if query else None
# 这次查询的 SQL 检查结果单独带上:preview 只有前 2000 个字符,checks 排在 rows 后面,通常被截掉
checks = event_checks(result) if name.startswith(QUERY_PREFIX) else []
ctx.emit(EventType.TOOL_END, tool=name, duration_ms=elapsed,
preview=json.dumps(result, ensure_ascii=False, default=str)[:2000],
artifact=snapshot_id, **({"query_artifact": query["artifact"]} if query else {}),
**({"schema_artifact": schema} if schema else {}))
**({"schema_artifact": schema} if schema else {}), **({"checks": checks} if checks else {}))
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
if evidence_on:
+151
View File
@@ -0,0 +1,151 @@
"""查询快照上的 SQL 检查问题:口径卡、出具契约、写作目录、运行日志共用一套读法。
数据源查询工具执行后对照数据目录检查 SQL,结果存在查询快照的 checks 里(data/sqlcheck.py)。error 级的
(一对多关联后重复计算、存量跨期加总)意味着这条查询算出来的数不可靠:
- 口径卡用它算出的指标标「存疑」(nodes/metrics.py);
- 报告直接引用它的格、或拿它当依据,出具同样降档(nodes/io.py);
- 写作目录里提醒写作者(engine/evidence.catalog_prompt)。
合并查询在内存 SQLite 上执行,不对照数据目录,它的快照没有自己的 checks,但记着 inputs:顺着往下找,问题记在
出问题的那条源查询上。几个指标、几处引用出自同一条查询时,问题只算一次(problems_by_query 按快照 id 归并)。
"""
from __future__ import annotations
import json
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any
from app.core import artifact_store
#: 合并查询快照的 source(engine/merge_query.MERGE_SOURCE)。这里写死同一个字面量,免得为一个常量引入执行器
MERGE_SOURCE = "合并查询"
#: 一条链最多追几层合并:画布上不会无限嵌套,这只是防御坏快照
_MAX_DEPTH = 8
@dataclass
class QueryProblem:
"""一条没通过 SQL 检查的查询快照。
artifact 是出问题的那条查询快照(合并查询的话是它的某个输入);node_id 是跑这条查询的节点,合并快照的 inputs
里记着,直接查询的由调用方按证据台账补(node_of);checks 是 error 级的检查结果(快照里的原样)。
"""
artifact: str
node_id: str | None
checks: list[dict[str, Any]] = field(default_factory=list)
@property
def problems(self) -> list[str]:
"""每条检查的问题那半句(message 的第一句),去重。改法留在证据面板的查询步骤里看。"""
out = [str(c.get("message") or "").split("。")[0] for c in self.checks]
return list(dict.fromkeys(p for p in out if p))
@property
def codes(self) -> list[str]:
return list(dict.fromkeys(str(c.get("code")) for c in self.checks if c.get("code")))
def _load(artifact: str, loader: Callable[[str], Any] | None) -> Any:
try:
return (loader or artifact_store.load)(artifact)
except Exception: # noqa: BLE001 - 快照读不出来、被改过:这里不报,证据层另有说法
return None
def query_problems(artifact: Any, *, loader: Callable[[str], Any] | None = None,
_seen: set[str] | None = None, _node: str | None = None, _depth: int = 0) -> list[QueryProblem]:
"""这份查询快照(或合并快照的各个输入)里有 error 级 SQL 检查的那几条查询,一条一个 QueryProblem。
读不出来的快照、没有问题的快照都不出现。自引用、成环的坏快照不会卡死。
"""
if not isinstance(artifact, str) or not artifact or _depth > _MAX_DEPTH:
return []
seen = _seen if _seen is not None else set()
if artifact in seen:
return []
seen.add(artifact)
snapshot = _load(artifact, loader)
if not isinstance(snapshot, dict):
return []
out: list[QueryProblem] = []
checks = snapshot.get("checks")
errors = [c for c in checks if isinstance(c, dict) and c.get("level") == "error"] if isinstance(checks, list) else []
if errors:
out.append(QueryProblem(artifact, _node, errors))
inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE else None
for entry in inputs if isinstance(inputs, list) else []:
if isinstance(entry, dict) and isinstance(entry.get("artifact"), str):
node = entry.get("node_id") if isinstance(entry.get("node_id"), str) else None
out += query_problems(entry["artifact"], loader=loader, _seen=seen, _node=node, _depth=_depth + 1)
return out
def error_checks(artifact: Any, *, loader: Callable[[str], Any] | None = None) -> list[dict[str, Any]]:
"""这份查询快照(含合并查询的输入)对照数据目录查出的全部 error 级检查结果,摊平成一个列表。"""
return [c for p in query_problems(artifact, loader=loader) for c in p.checks]
#: 运行事件、证据接口交给界面的检查字段(data/sqlcheck.SqlCheck.as_dict 去掉给模型的那句 for_model)
CHECK_FIELDS = ("code", "level", "message", "table", "column", "relation_id", "sql_excerpt")
def event_checks(result: Any) -> list[dict[str, Any]]:
"""数据源查询工具交回的结果(dict 或 JSON 文本)里的 SQL 检查结果,只留给人看的字段;没有就是空列表。
tool.end 的 preview 只是结果的前 2000 个字符,checks 排在 rows 后面,通常被截掉。事件里单独带一份,
运行时间线才看得到这条查询的检查结果。调用方只对数据源查询工具(db_query__)问这里。
"""
if isinstance(result, str):
text = result.lstrip()
if not text.startswith("{") or '"checks"' not in text:
return []
try:
result = json.loads(text)
except ValueError:
return []
checks = result.get("checks") if isinstance(result, dict) else None
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")]
def node_of(artifact: str, ledger: Iterable[Any] | None) -> str | None:
"""证据台账里交回这份查询快照的节点。台账里没有(老运行、Agent 没交台账)返回 None。"""
for entry in ledger or []:
if isinstance(entry, dict) and entry.get("kind") == "query" and entry.get("artifact") == artifact:
node = entry.get("node_id")
return node if isinstance(node, str) and node else None
return None
def problem_clause(problems: list[str]) -> str:
"""「未通过 SQL 检查(…),结果不可靠」:前面接上是哪条查询(「所依据的查询」「查询「取数」(Q1)」)。"""
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 reason_text(problems: list[str]) -> str:
"""「所依据的查询未通过 SQL 检查(…),结果不可靠」:口径卡、出具声明、运行日志、证据面板都用这句。"""
return "所依据的查询" + problem_clause(problems)
def merge_problems(found: Iterable[QueryProblem]) -> list[QueryProblem]:
"""按快照 id 归并:同一条查询经几条路径(直接引用、合并查询的输入)找到,只算一次。保持先后顺序。"""
out: dict[str, QueryProblem] = {}
for p in found:
if p.artifact not in out:
out[p.artifact] = QueryProblem(p.artifact, p.node_id, list(p.checks))
elif out[p.artifact].node_id is None and p.node_id:
out[p.artifact].node_id = p.node_id
return list(out.values())
__all__ = ["CHECK_FIELDS", "MERGE_SOURCE", "QueryProblem", "error_checks", "event_checks", "merge_problems", "node_of", "problem_clause", "query_problems",
"reason_text"]
+41 -6
View File
@@ -3,28 +3,63 @@
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, fail={"orders"})
await catalog.draft_with_model(model, source, ["visits"])
- 结构化输出(with_structured_output)返回自己;ainvoke 从提示词里认出这一批有哪些表(「表 <表名>」),
- 结构化输出(with_structured_output(...).ainvoke)从提示词里认出这一批有哪些表(「表 <表名>」),
返回 {"tables": [{"table": 表名, **replies[表名]}, …]};
- 直接 ainvoke 是纯文本那条路:同样的内容写成一段正文(AIMessage),前面带一句话;
- fail 里的表所在的那一批抛异常,用来验证「一批失败只影响这一批」;
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据)。
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据);modes 记下每次走的是
structured 还是 text。
structured、text 模拟实测见过的几种回复(两条路各自设):
- "tables":正常,{"tables": [...]};
- "hollow":退化的空壳,表名对上了、一个字段都没有:{"tables": [{"table": 表名}, …]};
- "single":只给第一张表的对象,没有 tables 外壳:{"table": 表名, …};
- "error":这条路用不了(网关不支持工具调用之类),一调就抛异常。
"""
from __future__ import annotations
import json
from typing import Any
class FakeModel:
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset()) -> None:
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset(), *,
structured: str = "tables", text: str = "tables") -> None:
self.replies, self.fail, self.calls = replies, set(fail), []
self.structured, self.text = structured, text
self.modes: list[str] = []
def with_structured_output(self, schema: Any, **_: Any) -> "FakeModel":
def with_structured_output(self, schema: Any, **_: Any) -> "_Structured":
assert schema["title"]
return self
return _Structured(self)
async def ainvoke(self, messages: list[Any]) -> dict:
async def ainvoke(self, messages: list[Any]) -> Any:
from langchain_core.messages import AIMessage
reply = self._reply(messages, "text")
return AIMessage(content="草稿如下:" + json.dumps(reply, ensure_ascii=False))
def _reply(self, messages: list[Any], mode: str) -> dict:
text = "\n".join(str(getattr(m, "content", m)) for m in messages)
self.calls.append(text)
self.modes.append(mode)
names = [t for t in self.replies if f"表 {t}\n" in text or f"表 {t}(" in text]
if any(t in self.fail for t in names):
raise RuntimeError("网关超时")
shape = self.structured if mode == "structured" else self.text
if shape == "error":
raise NotImplementedError("这条路用不了")
if shape == "hollow":
return {"tables": [{"table": t} for t in names]}
if shape == "single":
return {"table": names[0], **self.replies[names[0]]} if names else {}
return {"tables": [{"table": t, **self.replies[t]} for t in names]}
class _Structured:
def __init__(self, owner: FakeModel) -> None:
self.owner = owner
async def ainvoke(self, messages: list[Any]) -> dict:
return self.owner._reply(messages, "structured")
+3
View File
@@ -162,6 +162,9 @@ async def test_draft_defaults_to_top_tables_and_model_via_settings(client, sourc
body = r.json()
assert len(body["tables"]) == 20 and body["tables"][0]["table_name"] == "visits"
assert body["model_used"] is True and body["model"] == "fake-model"
# 每张表模型给了几项可用内容:模型给了内容的表是正数;回复里没有的表记 model_error,没有这个数
assert body["tables"][0]["model_items"] == 2 and body["tables"][0]["model_error"] is None
assert all(t["model_items"] is None and t["model_error"] for t in body["tables"][1:])
detail = (await client.get(f"{_base(source_id)}/visits")).json()
assert detail["notes"]["label"]["source"] == "llm" and detail["usage"] == 7
+59
View File
@@ -263,6 +263,41 @@ async def test_model_without_structured_output_falls_back_to_text():
assert got["visits"].notes == {"label": make_item("入园记录", "llm")}
# ---- 实测见过的几种退化回复(A2):一项都没写进去时不能说「没有变化」
async def test_model_single_table_object_without_wrapper_is_recognized():
"""纯文本兜底时只回了单个表对象,没有 tables 外壳:照样认出来。"""
model = FakeModel({"visits": {"label": "入园记录"}}, structured="error", text="single")
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
assert got["visits"].notes == {"label": make_item("入园记录", "llm")}
async def test_hollow_structured_output_retries_with_text():
"""结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳:改走纯文本再问一次,用纯文本给的内容。"""
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, structured="hollow", text="tables")
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
assert model.modes == ["structured", "text"]
assert got["visits"].notes == {"label": make_item("入园记录", "llm"), "kind": make_item("fact", "llm")}
async def test_hollow_structured_then_single_text_object():
model = FakeModel({"visits": {"label": "入园记录"}}, structured="hollow", text="single")
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
assert got["visits"].notes == {"label": make_item("入园记录", "llm")}
async def test_table_with_no_usable_content_is_a_model_error():
"""表名对上了、一个字段都没有(两条路都是空壳):这张表记模型失败,不算起草成功。"""
model = FakeModel({"visits": {"label": "入园记录"}}, structured="hollow", text="hollow")
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
assert got["visits"] == catalog.MODEL_EMPTY_REASON
# 给了字段、但没有一项合规(列名对不上、取值不在范围里)同样算没有可用内容
junk = FakeModel({"visits": {"kind": "planet", "columns": [{"name": "ghost", "label": "不存在的列"}]}})
got = await catalog.draft_with_model(junk, _source(_scenic_tables()), ["visits"])
assert got["visits"] == catalog.MODEL_EMPTY_REASON
async def test_model_draft_respects_system_notes_of_imported_tables():
"""导入表格:表说明已经写了粒度、列说明写了单位,模型起草的粒度和单位不进目录,免得重复或矛盾。"""
src = _source({"日报": _table(_col("日期", "TEXT", comment="格式 YYYY-MM-DD"), _col("客流"), pk=(),
@@ -383,6 +418,30 @@ async def test_draft_catalog_model_failure_on_one_table(monkeypatch):
assert by["channels"].model_error is None and by["channels"].added == 1
async def test_draft_catalog_empty_model_reply_is_not_no_change():
"""模型一项可用内容都没给:这张表记 model_error,没有 model_items,界面据此不说「没有变化」。
模型给了内容、只是和现有目录一致:没有 model_error,model_items 是模型给的项数。"""
row = await _db_source(_scenic_tables())
hollow = FakeModel({"visits": {"label": "入园记录"}}, structured="hollow", text="hollow")
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=hollow)
result = report.tables[0]
assert result.model_error == catalog.MODEL_EMPTY_REASON and result.model_items is None
assert (result.added, result.updated, result.removed) == (0, 0, 0)
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}})
async with SessionLocal() as session:
first = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=model)
again = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=model)
assert first.tables[0].model_items == 2 and first.tables[0].added == 2
assert again.tables[0].model_error is None and again.tables[0].model_items == 2
assert (again.tables[0].added, again.tables[0].updated, again.tables[0].removed) == (0, 0, 0)
# 不用模型时没有这个数
async with SessionLocal() as session:
plain = await catalog.draft_catalog(session, row, tables=["visits"], use_model=False)
assert plain.tables[0].model_items is None
async def test_draft_catalog_defaults_to_most_used_tables(monkeypatch):
tables = {f"t{i:02d}": _table(_col("id"), fks=[]) for i in range(25)}
row = await _db_source(tables)
+74 -1
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import asyncio
import re
import sqlite3
import uuid
from pathlib import Path
@@ -194,9 +195,15 @@ async def test_profile_verifies_name_relations_and_proposes_codes_and_business_d
assert (rel["source"], rel["status"]) == ("profile", "verified")
assert rel["coverage"] == 1.0 and rel["cardinality"] == "many_to_one"
assert "数据剖析" in rel["note"] and "抽样" in rel["note"]
# 子表一侧数过 COUNT 和 COUNT(DISTINCT)、确有重复:基数是用数据核实的(A6,一对多关联的检查据此给 error)
assert rel["cardinality_checked"] is True
# A7:说明里不写日期,时间看这一项的 updated_at(界面按本地时间显示)
assert rel["note"].startswith("数据剖析:") and not re.search(r"\d{4}-\d{2}-\d{2}", rel["note"])
assert rel["updated_at"]
# 外键约束来的关系不动:外键是外键,剖析是剖析
park = _relation(notes, "park_id")
assert (park["source"], park["status"], park["coverage"]) == ("fk", "verified", None)
assert "cardinality_checked" not in park # 外键的基数是按表结构推的,没用数据核对
# 状态列:码值候选,值有了、含义留空,推断
codes = notes["columns"]["status"]["codes"]
@@ -265,6 +272,8 @@ async def test_confirmed_relation_only_gets_coverage_and_cardinality(client, mak
rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id")
assert (rel["source"], rel["status"]) == ("name", "confirmed")
assert rel["coverage"] == 1.0 and rel["cardinality"] == "many_to_one"
# 人定的基数不改;原来就有基数(命名推断记的多对一),剖析也不给它记「用数据核实过」
assert "cardinality_checked" not in rel
finding = next(f for f in _table(r.json(), "visits")["findings"] if f.get("columns") == ["gate_id"])
assert finding["status"] == "confirmed" and finding["confirmed"] is True
@@ -381,9 +390,10 @@ async def test_large_tables_get_no_full_scans(client, make_source, scenic_db, sp
assert not any("MIN(" in s for s in on_visits)
assert "too_large" in _reasons(visits)
assert any("太大" in s["detail"] or "上限" in s["detail"] for s in visits["skipped"] if s["reason"] == "too_large")
# 父表有主键,覆盖率又够:照样有确证;子表一侧没核对唯一,按多对一记
# 父表有主键,覆盖率又够:照样有确证;子表一侧没核对唯一,按多对一记,不算用数据核实过基数
rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id")
assert rel["status"] == "verified" and rel["cardinality"] == "many_to_one"
assert not rel.get("cardinality_checked")
# ---------------------------------------------------------------- 接口
@@ -426,6 +436,69 @@ async def test_default_tables_are_used_tables_with_relation_candidates(client, m
assert "parks" not in names and "channels" not in names # 没有待核实关系的表不选
async def _candidates(sid: str) -> tuple[list[str], set[str]]:
"""默认挑表的候选(目录里有要核对的关系的表,按表结构顺序)和其中还有待核实(推断)关系的那些。"""
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
entries = await catalog.read_catalog(session, sid)
order = list((row.schema_cache or {}).get("tables") or {})
rels = {name: catalog_profile._relation_candidates(entries[name].notes) for name in order if name in entries}
names = [name for name in order if rels.get(name)]
return names, {name for name in names if any(r["status"] == "proposed" for r in rels[name])}
async def test_default_pick_skips_empty_tables_and_prefers_pending_relations(client, make_source, tmp_path,
monkeypatch):
"""B1:表都没被运行查询过(使用次数都是 0)时,以前按表结构顺序取前几张,结果大半是空表(景区库里排在前面的
候选表正好多数没有数据)。现在跳过行数估计为 0 的表(记在 empty_tables 里),并且先挑还有待核实关系的表,
不把刚核实过的再剖析一遍。默认张数调成 3,好在一个小库上看出先后。"""
monkeypatch.setattr(catalog_profile, "PROFILE_DEFAULT_TABLES", 3)
path = scenic.build(tmp_path / "b1.db")
db = sqlite3.connect(path)
try:
tables = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY rowid")]
empty = {t for t in tables if db.execute(f'SELECT COUNT(*) FROM "{t}"').fetchone()[0] == 0}
finally:
db.close()
sid = await make_source(str(path), options=_on(), draft=tables)
names, pending = await _candidates(sid)
full = [name for name in names if name not in empty]
# 前提:按表结构顺序的前 3 张候选里有空表(以前就会挑中它们),有数据、还待核实的候选不止 6 张
assert set(names[:3]) & empty
assert len([name for name in full if name in pending]) >= 6
first = (await client.post(_url(sid), json={}, headers=ACTOR)).json()
picked = [t["table_name"] for t in first["tables"]]
assert len(picked) == 3 and not set(picked) & empty, picked
assert first["empty_tables"] and set(first["empty_tables"]) <= empty
assert all(t["queries"] > 0 for t in first["tables"])
assert first["queries_used"] >= sum(t["queries"] for t in first["tables"]) + len(first["empty_tables"])
# 第二次:先挑还有待核实关系的表;第一次核实过的(关系都升为有确证了)排到后面,这次挑不到
names, pending = await _candidates(sid)
assert not set(picked) & pending # 第一次挑的三张,关系都核实了
second = (await client.post(_url(sid), json={}, headers=ACTOR)).json()
again = [t["table_name"] for t in second["tables"]]
assert len(again) == 3 and all(name in pending for name in again), (again, pending)
assert not set(again) & set(picked) and not set(again) & empty
async def test_profile_note_when_every_candidate_is_empty(client, make_source, tmp_path):
path = scenic.build(tmp_path / "b1-empty.db")
sid = await make_source(str(path), options=_on(), draft=[])
names, _ = await _candidates(sid)
db = sqlite3.connect(path)
try:
for name in names:
db.execute(f'DELETE FROM "{name}"')
db.commit()
finally:
db.close()
body = (await client.post(_url(sid), json={}, headers=ACTOR)).json()
assert body["tables"] == [] and set(body["empty_tables"]) == set(names)
assert "空表" in body["note"]
async def test_unknown_table_and_missing_source(client, make_source, scenic_db):
sid = await make_source(scenic_db, options=_on(), draft=["visits"])
r = await client.post(_url(sid), json={"tables": ["nope"]}, headers=ACTOR)
+6 -1
View File
@@ -146,7 +146,12 @@ def test_frozen_catalog_strips_rejected_and_keeps_versions():
assert set(frozen) == {"visits"} # 表结构里没有的、全被驳回的都不冻结
assert frozen["visits"]["version"] == 4
assert "dedup" not in frozen["visits"]["notes"]
assert len(frozen["visits"]["notes"]["relations"]) == 2
# 两条没被驳回的关系原样冻结;驳回的那条只留编号和状态(重建检查器时不被按外键、命名推回来)
relations = frozen["visits"]["notes"]["relations"]
assert len([r for r in relations if r.get("status") != "rejected"]) == 2
assert [r for r in relations if r.get("status") == "rejected"] == [
{"id": r["id"], "status": "rejected"} for r in VISITS["relations"] if r.get("status") == "rejected"]
assert len(catalog.visible_notes(frozen["visits"]["notes"])["relations"]) == 2
assert catalog.frozen_catalog({}, ["visits"]) == {}
+51 -1
View File
@@ -137,6 +137,8 @@ async def test_confirming_a_profiled_relation_by_patch_keeps_the_fanout_check(cl
rel = _relation(detail["notes"], "gate_id")
assert (rel["source"], rel["status"], rel["cardinality"], rel["coverage"]) == (
"profile", "verified", "many_to_one", 1.0)
# 子表一侧数过、确有重复:基数用数据核实过,一对多确有其事 → error(A6)
assert rel["cardinality_checked"] is True
assert ("fanout_sum", "error", "gates", "id") in await _found(sid, sql)
changes = [{"path": "relations.new", "value": {"columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"]},
@@ -149,6 +151,21 @@ async def test_confirming_a_profiled_relation_by_patch_keeps_the_fanout_check(cl
assert ("fanout_sum", "error", "gates", "id") in await _found(sid, sql)
async def test_profiled_relation_without_child_check_only_warns(client, make_source, scenic_db):
"""A6:子表超过整表统计上限、没核对子表一侧唯不唯一的关系,剖析照样记有确证、按多对一记,但基数不算用数据
核实过:扇出检查只给 warning(「可能重复计算」),不给 error、不让出具降档。"""
sql = "SELECT COUNT(g.id) AS n FROM gates g JOIN visits v ON v.gate_id = g.id"
sid = await make_source(scenic_db, options=_on(max_scan_rows=100), draft=["visits", "gates"])
r = await client.post(_url(sid), json={"tables": ["visits"]})
assert r.status_code == 200, r.text
rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id")
assert (rel["source"], rel["status"], rel["cardinality"]) == ("profile", "verified", "many_to_one")
assert not rel.get("cardinality_checked")
found = await _found(sid, sql)
assert ("fanout_sum", "warning", "gates", "id") in found
assert ("fanout_sum", "error", "gates", "id") not in found
# ==========================================================================
# A2:去掉推断或外键来的关系即驳回
# ==========================================================================
@@ -193,6 +210,19 @@ def test_human_edit_rejects_a_removed_human_relation_that_structure_would_bring_
assert catalog.apply_human_edit({"relations": [park]}, {"relations": []}, table="visits", at=AT) == {}
def test_human_edit_cannot_forge_a_data_checked_cardinality():
"""A6:「基数用数据核实过」只能由数据剖析写。人工改了关系(记成人工确认),提交里带的 cardinality_checked 不作数;
值没动的关系保留剖析原来写的。"""
gate = {**_rel(["gate_id"], "gates", ["id"], "profile", "verified"), "coverage": 1.0, "cardinality_checked": True}
park = _rel(["park_id"], "parks", ["id"], "name")
forged = {**park, "cardinality": "one_to_many", "cardinality_checked": True}
out = catalog.apply_human_edit({"relations": [gate, park]}, {"relations": [gate, forged]}, table="visits", at=AT)
by_id = {r["id"]: r for r in out["relations"]}
assert by_id[gate["id"]]["cardinality_checked"] is True
assert (by_id[park["id"]]["source"], by_id[park["id"]]["status"]) == ("human", "confirmed")
assert "cardinality_checked" not in by_id[park["id"]]
async def test_removed_or_rejected_relations_stay_out_of_checks_graph_and_redraft(client, make_source, scenic_db):
sid = await make_source(scenic_db, draft=["visits"])
detail = await _notes(client, sid, "visits")
@@ -377,7 +407,9 @@ async def test_rerun_on_another_day_with_same_data_keeps_the_version(client, mak
third = _table((await client.post(_url(sid), json={"tables": ["visits"]})).json(), "visits")
assert third["version"] == second["version"] + 1 and third["updated"] >= 1
codes = (await _notes(client, sid, "visits"))["notes"]["columns"]["status"]["codes"]
assert set(codes["value"]) == {"0", "1", "2"} and "2026-10-03" in codes["note"]
assert set(codes["value"]) == {"0", "1", "2"} and codes["updated_at"] == DAY3
# A7:说明里不写日期(以前写的是 UTC 日期,和界面上的本地时间对不上),时间以这一项的 updated_at 为准
assert codes["note"].startswith("数据剖析:") and "2026-10-03" not in codes["note"]
def test_merge_ignores_a_profile_note_that_only_changed_its_date():
@@ -394,6 +426,24 @@ def test_merge_ignores_a_profile_note_that_only_changed_its_date():
assert stats.updated == 1 and "2026-10-02" in merged["columns"]["status"]["codes"]["note"]
def test_profile_notes_without_date_stay_compatible_with_dated_ones():
"""A7:剖析写的说明不再带日期(「数据剖析:…」)。升级前写的「数据剖析(2026-10-01):…」照样认得是剖析写的;
结论一样、只是新的不带日期,不算变化——升级后第一次重新剖析不能因此给每张表升一个版本。"""
dated = "数据剖析(2026-10-01):统计全表,1500 行非空值共 2 个取值。"
plain = "数据剖析:统计全表,1500 行非空值共 2 个取值。"
assert catalog.is_profile_note(dated) and catalog.is_profile_note(plain)
assert not catalog.is_profile_note("人工备注:数据剖析(2026-10-01)") and not catalog.is_profile_note(None)
assert catalog.same_profile_note(dated, plain) and catalog.same_profile_note(plain, dated)
assert catalog.same_profile_note("数据剖析(2026-10-02):统计全表,1500 行非空值共 2 个取值。", dated)
assert not catalog.same_profile_note(dated, "数据剖析:统计全表,1600 行非空值共 2 个取值。")
assert not catalog.same_profile_note(plain, "统计全表,1500 行非空值共 2 个取值。")
old = {"columns": {"status": {"codes": catalog.make_item({"1": "", "0": ""}, "profile", "proposed", note=dated,
at=DAY1)}}}
new = {"columns": {"status": {"codes": catalog.make_item({"1": "", "0": ""}, "profile", "proposed", note=plain)}}}
merged, stats = catalog.merge_notes(old, new, covered={"profile"}, at=DAY2)
assert merged == old and stats == catalog.MergeStats()
# ==========================================================================
# B3:公式在人工编辑、模型起草两条路径上也拦下
# ==========================================================================
+7 -1
View File
@@ -116,6 +116,9 @@ async def test_read_catalog_lists_all_tables_of_a_source():
({"formula": _label("sum(amount)")}, "formula"), # 目录不放公式
({"relations": [{"id": "r1", "columns": ["a"], "to_table": "t", "to_columns": [],
"source": "fk", "status": "verified"}]}, "关联关系 r1"),
# 基数是否用数据核实过(数据剖析写):只能是 true / false
({"relations": [{"id": "r1", "columns": ["a"], "to_table": "t", "to_columns": ["id"], "cardinality": "many_to_one",
"source": "profile", "status": "verified", "cardinality_checked": "yes"}]}, "关联关系 r1"),
])
def test_validate_rejects_malformed_notes(notes, fragment):
problems = catalog.validate_notes(notes)
@@ -134,7 +137,10 @@ def test_validate_accepts_full_example():
"codes": _label({"1": "有效"}, "comment", "proposed")}},
"relations": [{"id": catalog.relation_id("visits", ["store_id"], "stores", ["id"]), "columns": ["store_id"],
"to_table": "stores", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": None,
"source": "fk", "status": "verified"}],
"source": "fk", "status": "verified"},
{"id": catalog.relation_id("visits", ["gate_id"], "gates", ["id"]), "columns": ["gate_id"],
"to_table": "gates", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": 1.0,
"source": "profile", "status": "verified", "cardinality_checked": True}],
}
assert catalog.validate_notes(notes) == []
@@ -0,0 +1,118 @@
"""证据面板的查询步骤:合并查询的输入带上检查结果,查询步骤说明用的是哪一版数据目录。
- 合并查询的列追不到逐格来历时,证据面板只有合并步骤、没有输入步骤:输入查询的 SQL 检查结果以前在这里看不到,
指标却因为它标了「存疑」。现在合并步骤的每个输入带上 checks(不带给模型的那句)。
- 表结构快照里冻结了数据目录(随查询冻结的那一版),以前没有任何读取方。查询步骤带上表结构快照 id 和这条查询
用到的表的目录版本,面板上写「数据目录:入园记录 第 3 版」。
- 冻结时保留被驳回关系的编号(只放编号和状态):拿冻结的那份重建检查器,按外键现推的关系不会把驳回的推回来。
"""
from __future__ import annotations
from app.api import evidence as api
from app.core import artifact_store
from app.data import catalog, sqlcheck
from app.engine.merge_query import MERGE_SOURCE, MergeInput, execute
FANOUT = {"code": "fanout_sum", "level": "error", "message": "「订单」关联「订单明细」是一对多,求和会重复计算。先汇总再关联",
"for_model": "orders 关联 order_items 后对 total_amount 求和会重复计算", "table": "orders"}
NOTICE = {"code": "missing_valid_filter", "level": "warning", "message": "没有筛掉作废订单", "for_model": "加 status = 1",
"table": "orders"}
async def put(obj, kind="query_snapshot") -> str:
return await artifact_store.put_json(obj, kind=kind)
def _sealed(queries: dict[str, dict]) -> api._Sealed:
seal = {"sealed": True, "ok": True, "manifest_seq": 9, "legacy": False}
return api._Sealed(run_id="run-x", graph={}, seal=seal, events=[], queries=queries)
async def test_merge_inputs_carry_their_sql_checks():
sales = {"columns": ["日期", "销售额"], "rows": [["2026-05-01", 10]], "row_count": 1, "truncated": False,
"sql": "SELECT 日期, SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id",
"source": "shop", "checks": [FANOUT, NOTICE]}
visits = {"columns": ["日期", "到店"], "rows": [["2026-05-01", 3]], "row_count": 1, "truncated": False,
"sql": "SELECT 日期, 到店 FROM 到店", "source": "foot"}
sid, vid = await put(sales), await put(visits)
outcome = execute([MergeInput(alias="s", node_id="q_sales", artifact=sid, snapshot=sales),
MergeInput(alias="v", node_id="q_visits", artifact=vid, snapshot=visits)],
'SELECT s."日期", s."销售额" * 1.0 / v."到店" AS 店均 FROM s JOIN v ON s."日期" = v."日期"')
mid = await put(outcome.snapshot())
doc = {"catalog": {"Q1": {"kind": "query", "artifact": sid}, "Q2": {"kind": "query", "artifact": vid},
"Q3": {"kind": "query", "artifact": mid, "source": MERGE_SOURCE}}}
sealed = _sealed({sid: {"node_id": "q_sales"}, vid: {"node_id": "q_visits"}, mid: {"node_id": "merge"}})
# 算出来的列追不到逐格来历:只有合并步骤,输入的检查结果要在合并步骤里看得到
[step] = await api._query_steps(mid, [(0, "店均")], doc, sealed, api._Masks())
s_in, v_in = step["merge"]["inputs"]
assert [c["code"] for c in s_in["checks"]] == ["fanout_sum", "missing_valid_filter"]
assert all("for_model" not in c for c in s_in["checks"])
assert s_in["checks"][0]["level"] == "error" and s_in["checks"][0]["table"] == "orders"
assert "checks" not in v_in
def _schema_cache() -> dict:
return {"schema": "main", "tables": {
"visits": {"qualified": "main.visits", "primary_key": ["id"],
"columns": [{"name": "id", "type": "INTEGER"}, {"name": "member_id", "type": "INTEGER"},
{"name": "fee", "type": "REAL"}],
"foreign_keys": [{"columns": ["member_id"], "to_table": "members", "to_columns": ["id"]}]},
"members": {"qualified": "main.members", "primary_key": ["id"],
"columns": [{"name": "id", "type": "INTEGER"}], "foreign_keys": []}}}
def _fk_relation(cache: dict) -> dict:
[rel] = [r for r in catalog.fk_relations(cache, "visits") or [] if r["to_table"] == "members"]
return rel
async def test_the_query_step_names_the_catalog_version_it_was_checked_against():
cache = _schema_cache()
notes = {"label": catalog.make_item("入园记录", "human")}
frozen = catalog.frozen_catalog({"visits": catalog.CatalogEntry("visits", notes, 3)}, cache["tables"])
schema_id = await put({"source": "scenic", **cache, "catalog": frozen}, kind="schema_snapshot")
query = {"columns": ["n"], "rows": [[4]], "row_count": 1, "truncated": False, "source": "scenic",
"sql": "SELECT COUNT(*) AS n FROM visits v JOIN members m ON m.id = v.member_id",
"schema_artifact": schema_id}
qid = await put(query)
doc = {"catalog": {"Q1": {"kind": "query", "artifact": qid}}}
step = await api._query_step(qid, [(0, "n")], doc, _sealed({qid: {"node_id": "fetch"}}), api._Masks())
assert step["schema_artifact"] == schema_id
# 只列这条查询用到、而且有目录的表:members 没有目录,不列
assert step["catalog"] == [{"table": "visits", "label": "入园记录", "version": 3}]
async def test_a_query_without_a_frozen_catalog_says_nothing_extra():
"""没对照过目录的查询(老快照、没有目录的源):步骤的形状和以前一字不差(期 4 的金样逐字比对)。"""
schema_id = await put({"source": "scenic", **_schema_cache()}, kind="schema_snapshot")
for extra in ({}, {"schema_artifact": schema_id}):
query = {"columns": ["n"], "rows": [[4]], "row_count": 1, "truncated": False, "source": "scenic",
"sql": "SELECT COUNT(*) AS n FROM visits", **extra}
qid = await put(query)
doc = {"catalog": {"Q1": {"kind": "query", "artifact": qid}}}
step = await api._query_step(qid, [(0, "n")], doc, _sealed({qid: {"node_id": "fetch"}}), api._Masks())
assert "catalog" not in step and "schema_artifact" not in step
def test_frozen_catalog_keeps_rejected_relation_ids_without_their_content():
cache = _schema_cache()
rel = {**_fk_relation(cache), "status": "rejected", "note": "这条外键是历史遗留,不能拿来关联"}
notes = {"label": catalog.make_item("入园记录", "human"), "relations": [rel]}
frozen = catalog.frozen_catalog({"visits": catalog.CatalogEntry("visits", notes, 2)}, cache["tables"])
assert frozen["visits"]["notes"]["relations"] == [{"id": rel["id"], "status": "rejected"}]
assert catalog.visible_notes(frozen["visits"]["notes"]) == {"label": notes["label"]}
# 拿冻结的那份重建检查器:驳回过的外键关系不会按表结构又推回来
checker = sqlcheck.frozen_checker({"source": "scenic", **cache, "catalog": frozen}, kind="sqlite")
assert checker is not None and checker.edges_between("visits", "members") == []
live = sqlcheck.SqlChecker(kind="sqlite", schema_cache=cache, notes={"visits": notes})
assert live.edges_between("visits", "members") == [] # 和当时库里那份一致
# 只有驳回关系的表也要冻结:不然编号丢了,重建时又推回来
only = catalog.frozen_catalog({"visits": catalog.CatalogEntry("visits", {"relations": [rel]}, 1)}, cache["tables"])
assert only == {"visits": {"version": 1, "notes": {"relations": [{"id": rel["id"], "status": "rejected"}]}}}
def test_a_snapshot_without_a_catalog_rebuilds_no_checker():
assert sqlcheck.frozen_checker({"source": "scenic", **_schema_cache()}, kind="sqlite") is None
assert sqlcheck.frozen_checker(None, kind="sqlite") is None
+6
View File
@@ -462,6 +462,12 @@ async def test_warnings_reach_the_output_and_the_events(monkeypatch, sources):
logs = [e for e in await events(row.id, "log", "merge") if e.get("level") == "warn"]
assert sorted(e["code"] for e in logs) == ["merge_key_type", "merge_rows_grew"]
assert all(e["message"] for e in logs)
# 结构化字段随日志带上:运行时间线读字段,不从中文里解析
key = next(e for e in logs if e["code"] == "merge_key_type")
assert key["keys"] == ["s.门店", "v.门店"] and key["detail"].startswith("s.门店 是文本") and "v.门店 是数值" in key["detail"]
grew = next(e for e in logs if e["code"] == "merge_rows_grew")
assert grew["rows"] == done["rows"] and grew["input"] in ("s", "v") and isinstance(grew["input_rows"], int)
assert grew["rows"] > grew["input_rows"] and "有重复的键" in grew["duplicates"]
async def test_rerunning_the_node_on_the_same_inputs_gives_the_same_snapshot(monkeypatch, sources):
@@ -0,0 +1,90 @@
"""写作指引:[[see:]] 只放句末;涉及码值时写含义、不写码值本身(R2 走查的 A4、B4)。
- A4:报告正文出现「以 为依据看」这样的断句。[[see:]] 渲染后不显示,写作者把它写进了句子中间
(「以 [[see:Q1]] 为依据看,……」)。指引写明只放句末;组装文档时能稳妥认出的写法(「以 [[see:…]] 为」
「根据 [[see:…]]」之类)报出来,交回写作者重写;重写不好也不判失败——数字和出处都没问题,只是句子不通。
- B4:报告核对把码值 1、0、9 当成没有出处的数字,每次运行都强制重写一轮。数字核对不放宽(不可回退的约定),
指引里写明:涉及码值时写它的含义(「作废记录」),不写码值本身。
"""
from __future__ import annotations
import pytest
from app.api.copilot import NODE_REFERENCE as COPILOT_PROMPT
from app.engine.evidence import SEE_AND_CODE_RULES, build_catalog, compose_doc
from app.engine.nodes.report import REWRITE_ONLY
from tests.test_report_node import ( # noqa: F401 - 夹具按名字引入
CLEAN,
engine_up,
events,
finish,
script,
weekly,
)
def test_the_writer_is_told_to_put_see_at_the_end_and_write_code_meanings():
assert "[[see:…]]" in SEE_AND_CODE_RULES and "句末" in SEE_AND_CODE_RULES
assert "以 [[see:Q1]] 为依据" in SEE_AND_CODE_RULES # 点名不要这样写
assert "码值" in SEE_AND_CODE_RULES and "含义" in SEE_AND_CODE_RULES and "作废记录" in SEE_AND_CODE_RULES
def test_the_copilot_tells_report_instructions_the_same():
"""助手写报告节点的指引(copilot 的系统提示)同样说清:see 只放句末,码值写含义。"""
assert "只能放在句末" in COPILOT_PROMPT and "以 [[see:" not in COPILOT_PROMPT.replace("不要写「以 [[see:", "")
assert "码值" in COPILOT_PROMPT and "含义" in COPILOT_PROMPT
@pytest.fixture
def catalog():
card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1",
"metrics": [{"id": "gmv", "name": "销售额", "value": 45678.5, "unit": "元", "format": "thousands"}]}
return build_catalog(nodes={"card": card}, ledger=[{"kind": "metric_set", "node_id": "card", "artifact": "c" * 64}])
@pytest.mark.parametrize("text", [
"以 [[see:m:gmv]] 为依据看,本周销售额 [[m:gmv]] 偏高。",
"以[[see:m:gmv]]为依据,本周销售额 [[m:gmv]] 偏高。",
"根据 [[see:m:gmv]],本周销售额 [[m:gmv]] 偏高。",
"本周销售额 [[m:gmv]] 偏高,详见 [[see:m:gmv]]。",
"基于 [[see:m:gmv]] 的结果,本周销售额 [[m:gmv]] 偏高。",
])
def test_see_used_as_part_of_the_sentence_is_flagged(catalog, text):
doc = compose_doc(text, catalog)
[v] = [v for v in doc["violations"] if v["code"] == "inline_see"]
assert "[[see:" in v["text"] and "句末" in v["for_model"]
assert "断句" in v["message"] and "[[see:" not in doc["markdown"]
@pytest.mark.parametrize("text", [
"本周销售额 [[m:gmv]] 偏高。[[see:m:gmv]]",
"本周销售额 [[m:gmv]] 偏高 [[see:m:gmv]]。",
"本周销售额 [[m:gmv]] 偏高 [[see:m:gmv]],所以 [[see:m:gmv]] 下周要关注。", # 「所以」不是介词
"这是结论的依据 [[see:m:gmv]]。",
"可以 [[see:m:gmv]]。",
])
def test_see_at_the_end_of_a_clause_is_fine(catalog, text):
doc = compose_doc(text, catalog)
assert not [v for v in doc["violations"] if v["code"] == "inline_see"], doc["violations"]
INLINE = "## 本周概览\n\n以 [[see:m:gmv]] 为依据看,本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。"
async def test_inline_see_is_sent_back_for_one_rewrite(engine_up, monkeypatch):
seen = script(monkeypatch, INLINE, CLEAN)
row = await finish(weekly({"on_violation": "fail"}))
assert row.status == "succeeded", row.error
assert len(seen) == 2, "句中的依据应当交回重写一次"
assert "只能挂在句末" in str(seen[1][-1].content)
async def test_inline_see_that_survives_the_rewrite_does_not_fail_the_node(engine_up, monkeypatch):
"""数字、出处都对得上,只是句子不通:重写还这样也不判失败,留在核对结果里。"""
assert "inline_see" in REWRITE_ONLY
seen = script(monkeypatch, INLINE)
row = await finish(weekly({"on_violation": "fail"}))
assert row.status == "succeeded", row.error
assert len(seen) == 2
[checked] = [e.data for e in await events(row.id, "report.checked")]
assert checked["failed"] is False and [v["code"] for v in checked["violations"]] == ["inline_see"]
+200
View File
@@ -0,0 +1,200 @@
"""报告直接引用没通过 SQL 检查的查询时,出具同样降档;同一条查询的问题只记一次。
以前缺口只从口径卡的指标来:报告绕过口径卡、直接写 [[v:Q1.r0.gmv]] 引用一条一对多关联后求和的查询,
数字回指得上快照,出具照样盖「完整出具」的章;写作目录也只给指标标「存疑」,写作者不知道这条查询本身有问题。
另一头,同一条查询算出三个指标时,运行时间线和降档原因里同一句话各重复三遍。
"""
from __future__ import annotations
import pytest
import app.engine.issuance as issuance_mod
from app.core import artifact_store
from app.engine.issuance import sql_check_gaps
from app.engine.merge_query import MERGE_SOURCE as ENGINE_MERGE_SOURCE
from app.engine.runner import run_manager
from app.engine.sql_problems import MERGE_SOURCE, query_problems, reason_text
from tests.test_runtime_sqlcheck import ( # noqa: F401 - 夹具按名字引入
CLEAN_SQL,
FANOUT_SQL,
GMV,
GMV2,
REASON,
Writer,
_drop_created_sources,
chain,
engine_up,
events,
node,
scenic_source,
wait,
)
PROBLEM = "「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算"
def test_the_merge_source_literal_matches_the_engine():
assert MERGE_SOURCE == ENGINE_MERGE_SOURCE
assert reason_text([PROBLEM]) == REASON
def direct(source: str, sql: str, text: str) -> dict:
"""报告不经口径卡,直接引用查询结果里的格。"""
return chain([
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql}),
node("write", "report", instructions="写订单周报"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write"}),
])
async def test_a_cell_cited_from_a_failing_query_degrades_the_issuance(scenic_source, engine_up, monkeypatch):
writer = Writer(monkeypatch, text="本周订单金额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=direct(scenic_source, FANOUT_SQL, ""), input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded", issuance
[gap] = [g for g in issuance["gaps"] if "SQL 检查" in g]
assert gap.startswith("查询「fetch」(Q1)未通过 SQL 检查") and PROBLEM in gap
assert "受影响的引用:Q1 第 1 行「gmv」" in gap
# 写作目录里,这条查询本身就标着「存疑」:写作者引用它的格时知道要交代
prompt = writer.prompts[0]
q1 = prompt[prompt.index("- Q1:"):]
assert "这次查询未通过 SQL 检查" in q1.split("\n例:")[0] and PROBLEM in q1
async def test_the_same_report_on_a_clean_query_issues_formally(scenic_source, engine_up, monkeypatch):
writer = Writer(monkeypatch, text="本周订单金额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=direct(scenic_source, CLEAN_SQL, ""), input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]
assert "未通过 SQL 检查" not in writer.prompts[0]
def three(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"]}),
])
async def test_one_failing_query_is_one_gap_and_one_timeline_row(scenic_source, engine_up, monkeypatch):
Writer(monkeypatch, text="本周订单金额 [[m:gmv]],查询结果 [[v:Q1.r0.gmv]]。[[see:Q1]]")
run = await run_manager.start(graph=three(scenic_source, FANOUT_SQL), input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded"
[gap] = [g for g in issuance["gaps"] if "SQL 检查" in g]
assert gap.count(PROBLEM) == 1
assert "指标「订单金额」" in gap and "指标「订单金额(整形后)」" in gap and "Q1 第 1 行「gmv」" in gap
# 出具声明和事件另带结构化的 sql_checks:报告页在显眼处说明降档原因,不从缺口的中文里认(B5)
[found] = issuance["sql_checks"]
assert found == {"query": "查询「fetch」(Q1)", "node_id": "fetch", "problems": [PROBLEM],
"refs": ["指标「订单金额」", "指标「订单金额(整形后)」", "Q1 第 1 行「gmv」"], "gap": gap}, found
[declared] = [e.data for e in await events(row.id, "issuance")]
assert declared["sql_checks"] == issuance["sql_checks"]
# 运行时间线:同一条查询的问题一行,列出受影响的指标;带着结构化字段,界面不用解析中文
[log] = [e.data for e in await events(row.id, "log", "card") if e.data.get("code") == "metric_sql_check"]
assert log["metrics"] == ["gmv", "gmv2"] and log["metric_names"] == ["订单金额", "订单金额(整形后)"]
assert log["message"] == f"指标「订单金额」「订单金额(整形后)」{REASON}"
assert log["reason"] == REASON and log["problems"] == [PROBLEM]
assert log["source_node"] == "fetch" and log["source_field"] == "args.sql"
[check] = log["checks"]
assert check["code"] == "fanout_sum" and check["level"] == "error" and "for_model" not in check
[query_end] = [e.data for e in await events(row.id, "tool.end", "fetch")]
assert log["query_artifact"] == query_end["query_artifact"]
# --------------------------------------------------------------------------
# 合并查询:问题记在出问题的那条源查询上,经几条路径找到都只算一次
# --------------------------------------------------------------------------
FANOUT = {"code": "fanout_sum", "level": "error", "message": f"{PROBLEM}。改法:先汇总再关联"}
def _snap(checks=None, **extra):
snap = {"columns": ["日期", "门店", "销售额"], "rows": [["2026-05-01", "S01", 10]], "truncated": False, **extra}
return {**snap, "checks": checks} if checks else snap
@pytest.fixture
def snapshots(monkeypatch):
store = {
"sales": _snap([FANOUT]),
"visits": _snap(),
"merged": _snap(source=MERGE_SOURCE, inputs=[{"alias": "s", "node_id": "q_sales", "artifact": "sales"},
{"alias": "v", "node_id": "q_visits", "artifact": "visits"}]),
}
monkeypatch.setattr(artifact_store, "load", lambda artifact: store.get(artifact))
return store
def test_problems_are_recorded_on_the_input_that_failed(snapshots):
[problem] = query_problems("merged")
assert problem.artifact == "sales" and problem.node_id == "q_sales" and problem.problems == [PROBLEM]
def test_metrics_and_cells_through_a_merge_share_one_gap(snapshots):
metrics = [{"id": "gmv", "name": "销售额", "sql_check_failed": True, "sql_check_reason": REASON,
"sql_check_sources": [{"artifact": "merged", "codes": ["fanout_sum"]}]},
{"id": "avg", "name": "店均销售额", "sql_check_failed": True, "sql_check_reason": REASON,
"sql_check_sources": [{"artifact": "merged", "codes": ["fanout_sum"]}]}]
labels = {"sales": "查询「取销售」(Q1)"}
gaps = sql_check_gaps(metrics, {"merged": ["Q2 第 1 行「销售额」"], "sales": ["Q1(结论依据)"]},
label=lambda artifact, node: labels.get(artifact))
assert gaps == [f"查询「取销售」(Q1)未通过 SQL 检查({PROBLEM}),结果不可靠;"
"受影响的引用:指标「销售额」、指标「店均销售额」、Q2 第 1 行「销售额」、Q1(结论依据)"]
def test_a_flagged_metric_whose_snapshot_is_gone_still_counts(monkeypatch):
"""快照读不出来(被清理、被改过)时退回口径卡记下的那句:缺口不能因为读不到快照就消失。"""
monkeypatch.setattr(issuance_mod, "query_problems", lambda artifact, **_: [])
metrics = [{"id": "gmv", "name": "订单金额", "sql_check_failed": True, "sql_check_reason": REASON,
"sql_check_sources": [{"artifact": "gone", "codes": ["fanout_sum"]}]}]
assert sql_check_gaps(metrics) == [f"指标「订单金额」{REASON}"]
assert sql_check_gaps([{"id": "n", "name": "订单数", "value": 2}]) == []
# --------------------------------------------------------------------------
# 运行时间线:tool.end 单独带上这次查询的 SQL 检查结果
# --------------------------------------------------------------------------
async def test_tool_end_carries_the_sql_checks_of_the_query(scenic_source, engine_up, monkeypatch):
"""preview 只是结果的前 2000 个字符,checks 排在 rows 后面,通常被截掉:界面看不到这条查询的检查结果。"""
Writer(monkeypatch, text="本周订单金额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=direct(scenic_source, FANOUT_SQL, ""), input_payload={})
row = await wait(run.id)
[end] = [e.data for e in await events(row.id, "tool.end", "fetch")]
[check] = end["checks"]
assert check["code"] == "fanout_sum" and check["level"] == "error" and check["table"] == "orders"
assert PROBLEM in check["message"] and "for_model" not in check
clean = await run_manager.start(graph=direct(scenic_source, CLEAN_SQL, ""), input_payload={})
row = await wait(clean.id)
[end] = [e.data for e in await events(row.id, "tool.end", "fetch")]
assert "checks" not in end
def test_agent_and_team_tool_events_read_checks_the_same_way():
"""Agent 节点和协作团队里的查库调用交回的是 JSON 文本:同一个读法,取不到就不加。"""
import json
from app.engine.sql_problems import event_checks
text = json.dumps({"rows": [[1]], "checks": [{**FANOUT, "for_model": "改写"}]}, ensure_ascii=False)
assert event_checks(text) == [{k: v for k, v in FANOUT.items()}]
assert event_checks({"rows": [], "checks": [FANOUT]}) == [FANOUT]
assert event_checks("查询失败:no such table") == [] and event_checks(None) == []
assert event_checks('{"rows": [], "checks": "坏的"}') == []
+5 -2
View File
@@ -150,6 +150,7 @@ async def test_a_failing_or_slow_check_never_breaks_the_query(scenic_source, mon
def test_sql_check_failures_become_readable_gaps():
# 没记来源快照(或快照读不出来)时退回口径卡那句;按查询归并的写法见 test_report_sqlcheck_cells
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}"]
@@ -246,11 +247,13 @@ async def test_a_fanout_behind_a_contract_metric_degrades_the_issuance(scenic_so
"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}"]
# 同一条查询算出的两个指标:时间线上一行,降档原因里一句(按查询归并,列出受影响的指标)
assert [w["message"] for w in warns] == [f"指标「订单金额」「订单金额(整形后)」{REASON}"]
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded", issuance
assert f"指标「订单金额」{REASON}" in issuance["gaps"]
assert [g for g in issuance["gaps"] if "SQL 检查" in g] == [
f"查询「fetch」(Q1){REASON.removeprefix('所依据的查询')};受影响的引用:指标「订单金额」、指标「订单金额(整形后)」"]
# 写作目录提醒写作者:这个数存疑,引用时要说明
assert "所依据的查询未通过 SQL 检查,结果存疑" in writer.prompts[0]
+44 -2
View File
@@ -113,10 +113,19 @@ async def test_fanout_count_of_the_one_side_key(cache):
async def test_fanout_through_two_hops_with_an_inferred_foreign_key(cache):
"""支付记录没写目录:订单 → 支付记录的关系按外键现推(verified),明细经订单连支付记录,明细的金额照样重复。"""
"""支付记录没写目录:订单 → 支付记录的关系按外键现推(verified),明细经订单连支付记录,明细的金额可能重复。
这条一对多只是按外键推的(没用数据核对过子表一侧),所以是 warning(A6)。"""
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) == ("warning", "order_items", "amount")
assert "可能重复计算" in check.message and "「订单」关联「payments」" in check.message
# 人工确认了支付记录 → 订单这条关系(基数是人定的):一对多确有其事,error,说清明细怎么随之重复
notes = scenic_notes.notes()
notes["payments"] = {"relations": [scenic_notes._rel("payments", ["order_id"], "orders", ["id"], "many_to_one",
"confirmed")]}
checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=notes, source_name="scenic")
[check] = [c for c in checker.check(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
@@ -156,7 +165,9 @@ async def test_fanout_sum_negative(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", "error", "orders", "total_amount") in found(cache, sql, "confirmed")
# 外键 + verified:一对多只是按表结构推的,降为 warning(A6,见下一个测试)
assert ("fanout_sum", "warning", "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(
@@ -169,6 +180,37 @@ async def test_fanout_level_follows_the_status_of_its_basis(cache):
assert "fanout_sum" not in codes(cache, sql, override={("orders", "columns.total_amount.measure"): "rejected"})
def _checked(table_notes: dict, rid: str, *, source: str = "profile") -> dict:
"""把一条关系记成「剖析用数据核实过基数」(cardinality_checked),不改入参。"""
import copy
out = copy.deepcopy(table_notes)
rel = next(r for r in out["relations"] if r["id"] == rid)
rel.update(source=source, status="verified", cardinality_checked=True)
return out
async def test_fanout_error_only_when_one_to_many_is_established(cache):
"""A6:error 只给「一对多确有其事」——剖析在数据上核实过子表一侧的键不唯一,或者人工确认过基数。
只凭外键结构推出的一对多(子表一侧可能每个键恰好一行)降为 warning,说法是「可能重复计算」。"""
sql = FANOUT[0]
# 外键 + verified:基数是按表结构推的,没用数据核对过 → warning,不说「会重复计算」
[fk] = [c for c in run(cache, sql, "verified") if c.code == "fanout_sum"]
assert (fk.level, fk.table, fk.column) == ("warning", "orders", "total_amount")
assert "可能重复计算" in fk.message and "会重复计算" not in fk.message
assert "确认关联表" in fk.message and "每个键只有一行" in fk.message and "先汇总再关联" in fk.message
assert "必然" not in fk.message and "必然" not in fk.for_model
# 剖析核实过(关系上记 cardinality_checked)→ error
profiled = scenic_notes.notes("verified")
profiled["order_items"] = _checked(profiled["order_items"], ORDER_ITEMS_TO_ORDERS)
checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=profiled, source_name="scenic")
[hit] = [c for c in checker.check(sql) if c.code == "fanout_sum"]
assert hit.level == "error" and "会重复计算" in hit.message
# 人工确认过基数(关系是 confirmed)→ error
assert ("fanout_sum", "error", "orders", "total_amount") in found(
cache, sql, "verified", override={("order_items", f"relations.{ORDER_ITEMS_TO_ORDERS}"): "confirmed"})
# --------------------------------------------------------------------------
# stock_summed:存量跨时间求和
# --------------------------------------------------------------------------
+14 -2
View File
@@ -14,8 +14,8 @@ import mdSamples from '../run/__tests__/markdown-samples.json'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import {
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mergeRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun,
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, SQL_DEGRADED_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mergeRun, mixedRun, parallelQueryRun, pipelineRun, repairRun, teamRun, timeoutRun,
} from '../run/__tests__/synthetic'
import '../index.css'
@@ -302,6 +302,11 @@ function synthetic(name: string): StreamTurn[] {
steps: decodeRun(ev, { status: 'succeeded' }), output: MIXED_OUTPUT, runId: 'syn-issued',
runClass: 'exploratory', review: REVIEW_DEGRADED_TURN.review }]
}
case 'issued-sql':
// 数字都有出处,却因为查询没通过 SQL 检查而降档:横幅单列说明
return [{ id: name, question: '上周订单金额', phase: 'done', status: '已完成',
steps: decodeRun(parallelQueryRun(), { status: 'succeeded' }), runId: 'syn-issued-sql', runClass: 'formal',
output: SQL_DEGRADED_OUTPUT }]
case 'calibers':
case 'caliber-one': {
// 按口径卡清点回指的数字:两张卡各数各的;只有一张卡时直说都来自它
@@ -364,6 +369,10 @@ function synthetic(name: string): StreamTurn[] {
columns: ['factory_code', 'line_name', 'attribute_group', 'output_qty'],
rows: [['1063', '一号线', '001', 1520], ['1064', '二号线', '002', 1310], ['1065', '三号线', '001', 980]],
row_count: 3 }) } }]
case 'parallel':
// 两个并行的查询节点用同一个工具;第一条查询没通过 SQL 检查,口径卡的问题指到它的 SQL
return [{ id: 'parallel', question: '订单金额和退款数', phase: 'done', status: '已完成',
steps: decodeRun(parallelQueryRun(), { status: 'succeeded' }) }]
case 'merge':
// 合并查询:两个库各查一次、在库外按门店合并,带两道警告(键类型不一致、行数放大)
return [{ id: 'merge', question: '按门店合并销售和到店人数', phase: 'done', status: '已完成',
@@ -533,6 +542,9 @@ function Preview() {
turns={synthetic(syn)}
onStepHover={params.get('link') === '1' ? (id) => { (window as any).__hovered = id } : undefined}
onStepFocus={params.get('link') === '1' ? (id) => { linked.push(id) } : undefined}
// 「打开设置」:记下要打开哪个节点、落到哪一栏(指标的问题落到来源查询的 SQL)
onStepOpen={params.get('link') === '1'
? (id, field) => { ((window as any).__opened ??= []).push([id, field ?? null]) } : undefined}
renderTurnActions={(t) => (t.phase === 'error'
? <button type="button" className="btn btn-xs"><RotateCw size={11} aria-hidden /> 重试本轮</button>
: null)}
+6
View File
@@ -57,6 +57,12 @@ export function sqlCheckOf(v: unknown): SqlCheckItem | null {
}
}
/** 一组检查(事件、接口里的 checks 字段,助手自查的问题):认不出的丢掉 */
export function sqlChecksOf(v: unknown): SqlCheckItem[] {
const list: unknown[] = Array.isArray(v) ? v : []
return list.map(sqlCheckOf).filter((c): c is SqlCheckItem => c !== null)
}
/** 一条检查写成一行(时间线的展开区、交给助手再修的清单):「错误 · 一对多关联后重复计算:……(涉及 orders.amount)」 */
export function sqlCheckLine(c: SqlCheckItem): string {
const where = sqlCheckWhere(c)
+52 -4
View File
@@ -2051,7 +2051,10 @@ export const CATALOG_TEXT = {
draftFailed: '起草中断',
draftSummary: (added: number, updated: number, removed: number) =>
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
draftNoChange: '没有变化:起草结果与现有目录一致',
// 只在起草确实给出了内容、只是和现有目录一致时用:有表的模型起草失败或没给出可用内容(model_error)时
// 不能说「没有变化」,改用 draftNothingWritten
draftNoChange: '没有变化:起草出的内容与现有目录一致',
draftNothingWritten: '目录没有更新:模型没有给出可用的内容,原因见下方',
draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`,
draftModelUsed: (model: string) => `模型:${model}`,
draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`,
@@ -2545,7 +2548,8 @@ export const CATALOG_UI_TEXT = {
//
// 术语:
// - 「SQL 检查」:对照数据目录找出能执行、数却可能不对的写法。七条规则,界面上写中文规则名,不露规则编号。
// - 级别:错误(依据已确认,结果必然有误)、提醒(依据已确认,结果很可能有误)、提示(依据只是推断,仅供参考)。
// - 级别:错误(依据已核实,结果很可能有误)、提醒(依据已确认,结果可能有误)、提示(依据只是推断,仅供参考)。
// 一对多关联后重复计算:只有一对多人工确认过、或数据剖析用数据核实过时才是错误;只凭外键推出的是提醒。
// 颜色走语义令牌:错误 --st-failed,提醒 --st-waiting,提示 --accent。
// ===========================================================================
@@ -2576,8 +2580,8 @@ export const SQL_CHECK_LEVEL_LABEL: Record<SqlCheckLevel, string> = {
}
export const SQL_CHECK_LEVEL_HINT: Record<SqlCheckLevel, string> = {
error: '依据已确认,结果必然有误',
warning: '依据已确认,结果很可能有误',
error: '依据已核实,结果很可能有误,需要修改',
warning: '依据已确认,结果可能有误,请核对',
info: '依据尚未确认(推断),仅供参考',
}
@@ -2604,3 +2608,47 @@ export const SQL_CHECK_TEXT = {
assistantNextChat: '可在画布中打开此工作流,修改对应节点「参数」中的 SQL;检查依据有误时,请在数据目录中修正对应项',
assistantInfoNext: '这些检查的依据尚未确认,仅供参考。在数据目录中确认相关项后,检查结论更可靠',
}
// ===========================================================================
// 证据面板、运行时间线里的 SQL 检查和数据目录版本,助手「参考了哪些表」(数据目录完整性修复 F2a)
//
// - 证据面板的查询步骤写这次查询对照的是哪一版数据目录(表结构快照里冻结的那份):「数据目录:入园记录 第 3 版」。
// - 合并查询的每个输入带着它自己的 SQL 检查结果。
// - 运行时间线:同一条查询的问题只列一行,写明受影响的指标;「打开设置」落到来源查询节点的 SQL 上。
// - 助手「参考了哪些表」:带字段的表和只提供了表名的表分开数。
// ===========================================================================
export const RUN_SQL_CHECK_TEXT = {
/** 证据面板查询步骤:SQL 检查对照的数据目录版本 */
catalogLine: (items: { name: string; version?: number | null }[]) =>
`数据目录:${items.map((i) => (i.version != null ? `${i.name} 第 ${formatNumber(i.version)} 版` : i.name)).join('、')}`,
catalogHint: '这次查询时冻结的数据目录版本,SQL 检查对照的就是这一版',
/** 合并查询的输入:这个输入查询自己的 SQL 检查 */
mergeInputChecks: (alias: string) => `输入 ${alias} 的 SQL 检查`,
/** 运行时间线:一条查询的问题影响了几个指标 */
metricsTitle: (names: string[], n = 1) => {
const who = names.length > 3
? `指标${names.slice(0, 3).map((x) => `「${x}」`).join('')}等 ${formatNumber(names.length)} 个`
: `指标${names.map((x) => `「${x}」`).join('')}`
return n > 1 ? `${who}所依据的查询有 ${formatNumber(n)} 处未通过 SQL 检查` : `${who}所依据的查询未通过 SQL 检查`
},
/** 运行时间线:一次查询的 SQL 检查结果(查询那一行展开后) */
toolChecks: 'SQL 检查',
/** 「打开设置」落到来源查询节点的 SQL 上 */
openSql: (label?: string) => (label ? `打开「${label}」的 SQL` : '打开来源查询的 SQL'),
metricNext: '指标照常计算,但结果不可靠,出具按缺口降档。请修改来源查询的 SQL 后重新运行;检查依据有误时,请在数据目录中修正对应项',
/** 出具横幅、报告页:SQL 检查导致的降档单列一块 */
issuanceTitle: (tier: string) => (tier === 'degraded' ? '降档原因:所依据的查询未通过 SQL 检查' : '所依据的查询未通过 SQL 检查'),
issuanceQuery: '所依据的查询',
issuanceAffected: (refs: string) => `受影响:${refs}`,
issuanceNext: '数字的出处都对得上,但算出这些数的查询本身有问题,结果不可靠。请修改来源查询的 SQL 后重新运行;检查依据有误时,请在数据目录中修正对应项',
/** 其余缺口的标题(SQL 检查的那几条单列之后) */
otherGaps: '校验未全部完成:',
}
export const COPILOT_CONTEXT_TEXT = {
/** 标题:另有只提供了表名的表时,写明这个数只算带着字段的表 */
withFields: (n: number) => `参考了 ${formatNumber(n)} 张表的字段`,
namesOnly: (n: number) => `另有 ${formatNumber(n)} 张表只提供了表名`,
notPicked: '未能按需求挑选',
}
+9
View File
@@ -13,6 +13,7 @@ import {
} from '../../lib/evidence'
import { formatNumber } from '../../lib/format'
import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms'
import { IssuanceBanner } from '../../run/AssistantStream'
import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc'
import { EvidenceGuessView } from '../../run/EvidenceGuess'
import { Markdown, inlineStyles } from '../../run/Markdown'
@@ -142,6 +143,13 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
<SummaryBar mode={mode} seal={seal} message={audit.data?.seal?.message}
reports={graph.reports ?? []} labelOf={labelOf} />
<UpgradeBanner run={run} mode={mode} />
{/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的
「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */}
{!!output?._issuance?.tier && (
<div data-evidence-issuance="">
<IssuanceBanner issuance={output._issuance} runClass={run.run_class} />
</div>
)}
{mode === 'cited' && reports.map((r) => {
const doc = docOf(r.node_id)
@@ -157,6 +165,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
{doc ? (
<EvidenceDoc ref={(h) => { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact}
runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally
tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined}
panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} />
) : slot?.status === 'error' ? (
<p className="text-2xs" style={{ color: 'var(--st-failed)' }}>{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}</p>
+6 -2
View File
@@ -791,8 +791,12 @@ function RunView({ onBack, reveal, onRevealed }: {
clockSkewMs={trace.skewMs ?? 0}
onStepHover={setHoveredNode}
onStepFocus={focusNode}
// 没绑工具、轮数不够这种要去画布上改的:直接打开那个节点的设置,镜头也带过去
onStepOpen={(id) => { select(id); focusNode(id) }}
// 没绑工具、轮数不够这种要去画布上改的:直接打开那个节点的设置,镜头也带过去。
// 给了栏位(指标的问题出在来源查询的 SQL 上,field 是 args.sql)就落到那一栏
onStepOpen={(id, field) => {
if (field) revealField(id, field, { focus: true })
else { select(id); focusNode(id) }
}}
activeNodeId={hoveredNodeId}
reveal={stepReveal}
onRevealed={onRevealed}
+65 -16
View File
@@ -15,7 +15,8 @@ import { humanizeError } from '../lib/errors'
import { explainRunError } from '../lib/explain'
import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format'
import { statusLabel } from '../lib/status'
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { SqlCheckList } from './SqlChecks'
import {
ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf,
type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx,
@@ -24,7 +25,7 @@ import {
import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown'
import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc'
import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence'
import type { EvidenceDocData } from '../types'
import type { EvidenceDocData, IssuanceSqlCheck } from '../types'
import { useRunClock } from './useRunClock'
import { CatalogPatchCard } from './CatalogPatchCard'
import type { CatalogPatch } from './catalogPatch'
@@ -135,8 +136,8 @@ interface StreamCtx {
dense: boolean
onHover?: (nodeId: string | null) => void
onFocus?: (nodeId: string) => void
/** 打开这个节点的设置去改(编排页)。下一步是「去画布改」的行据此给直达入口 */
onOpen?: (nodeId: string) => void
/** 打开这个节点的设置去改(编排页)。下一步是「去画布改」的行据此给直达入口;给了 field 就落到那一栏(args.sql) */
onOpen?: (nodeId: string, field?: string) => void
activeNodeId?: string | null
skewMs: number
openArtifact: (id: string, title?: string) => void
@@ -196,7 +197,7 @@ export function AssistantStream({
* 编排页:打开某个节点的设置。节点没绑工具、团队轮数不够这类要去画布上改的,
* 报错和提醒行据此给「打开设置」;不传就只说该去哪儿改
*/
onStepOpen?: (nodeId: string) => void
onStepOpen?: (nodeId: string, field?: string) => void
/** 画布上悬停的节点:对应的步骤行高亮 */
activeNodeId?: string | null
/** 贴着底部时,新步骤到来自动滚到底。离开底部就不再拽人 */
@@ -877,19 +878,23 @@ function failureOf(turn: StreamTurn): Failure | null {
* 下一步的直达入口。要去画布上改的,编排页能直接打开那个节点的设置;别处只说该去哪儿,
* 不装作能点。「接着跑」「重新运行」由页面自己放(renderTurnActions),这里不给
*/
function FixAction({ fix, nodeId, label, to, first }: {
function FixAction({ fix, nodeId, label, to, first, field }: {
fix?: FixKind | 'rerun'; nodeId?: string; label?: string
/** lib/explain 的 fixTo:直达要改的那一项 */
to?: string
/** lib/explain 的 fixFirst:先改好才接得下去,入口写明要改什么 */
first?: boolean
/** 要改的那一栏(args.sql:调用工具节点里写死的 SQL)。给了就落到那一栏,入口写「打开「取数」的 SQL」 */
field?: string
}) {
const { onOpen } = useContext(Ctx)
const cls = 'inline-flex shrink-0 items-center gap-1 rounded px-1 text-2xs text-[var(--accent)] underline-offset-2 transition-colors hover:bg-hover hover:underline'
if (fix === 'canvas' && nodeId && onOpen) {
const sql = field === 'args.sql' || field === 'sql'
return (
<button type="button" className={cls} data-fix="canvas" onClick={() => onOpen(nodeId)}>
<Settings2 size={10} aria-hidden /> 打开{label ? `「${label}」的` : ''}设置
<button type="button" className={cls} data-fix="canvas" data-fix-node={nodeId} data-fix-field={field || undefined}
onClick={() => onOpen(nodeId, field || undefined)}>
<Settings2 size={10} aria-hidden /> {sql ? RUN_SQL_CHECK_TEXT.openSql(label) : `打开${label ? `「${label}」的` : ''}设置`}
</button>
)
}
@@ -1064,7 +1069,7 @@ function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?:
const [open, setOpen] = useState(false)
const Icon = ICONS[step.kind] ?? CircleDot
const result = step.result
const expandable = !!(step.detail || result || step.raw || step.artifact)
const expandable = !!(step.detail || result || step.raw || step.artifact || step.checks?.length)
const table = result ? parseQueryResult(result) : null
const status = step.status ?? 'done'
// 失败的节点:收着的时候就说为什么(和轮次顶上的报错同一份 lib/explain),展开再看原因、
@@ -1163,7 +1168,9 @@ function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?:
<CornerDownRight size={10} className="mt-[2px] shrink-0" aria-hidden />
<span className="min-w-0 [overflow-wrap:anywhere]">{step.next}</span>
</span>
<FixAction fix={step.fix} nodeId={step.nodeId} />
{/* 要改的是另一个节点(指标的问题出在来源查询的 SQL 上):入口落到那个节点的那一栏 */}
<FixAction fix={step.fix} nodeId={step.fixNode ?? step.nodeId} field={step.fixField}
label={step.fixNode ? step.fixLabel : undefined} />
</div>
)}
@@ -1518,6 +1525,13 @@ function StepDetail({ step, table, explained }: {
</pre>
</div>
)}
{!!step.checks?.length && (
// 对照数据目录的 SQL 检查:查询那一行是这次查询的检查结果,指标那一行是来源查询没通过的那几条
<div className="text-2xs" data-step-sql-checks="">
<div className="mb-0.5 text-dim">{RUN_SQL_CHECK_TEXT.toolChecks}</div>
<SqlCheckList checks={step.checks} />
</div>
)}
{table
? <ResultTable table={table} artifact={step.artifact} title={step.title} />
: result && result !== step.detail && (
@@ -1854,8 +1868,9 @@ export function ResultTable({ table, artifact, title, full = false, highlight, m
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-t px-1.5 py-1 text-2xs leading-relaxed text-dim">
<span className="tnum">
{[
rows.length < table.rows.length
? `显示前 ${formatNumber(rows.length)} / ${formatNumber(table.rows.length)} 行`
// total:结果一共几行(预览只存了前几行时比 rows 多,比如合并查询 6 行只预览 5 行)
rows.length < Math.max(table.total ?? 0, table.rows.length)
? `显示前 ${formatNumber(rows.length)} / ${formatNumber(Math.max(table.total ?? 0, table.rows.length))} 行`
: `${formatNumber(table.rows.length)} 行`,
hiddenCols > 0 ? `显示前 ${cols.length} / ${table.columns.length} 列` : null,
].filter(Boolean).join(' · ')}
@@ -2247,6 +2262,32 @@ function OutputValue({ value, marks, label }: { value: unknown; marks?: MarkSpec
)
}
/**
* 出具声明里没通过 SQL 检查的查询(sql_checks):每条写是哪次查询、什么问题、影响了哪些引用,下一步写改来源查询的 SQL。
* 出具横幅和报告页顶部共用。颜色跟着档位:降档用提醒色,不予出具用失败色
*/
export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: IssuanceSqlCheck[] }) {
const tone = tier === 'withheld' ? 'var(--st-failed)' : 'var(--st-waiting)'
return (
<div className="mt-1.5 rounded border px-2 py-1.5 text-2xs leading-relaxed" data-issuance-sql-checks={checks.length}
style={{ borderColor: `color-mix(in srgb, ${tone} 45%, var(--border))`, background: `color-mix(in srgb, ${tone} 6%, transparent)` }}>
<div className="flex items-center gap-1 font-medium" style={{ color: tone }}>
<AlertTriangle size={11} aria-hidden /> {RUN_SQL_CHECK_TEXT.issuanceTitle(tier)}
</div>
<ul className="mt-0.5 space-y-0.5">
{checks.map((c, i) => (
<li key={`${c.query ?? ''}:${i}`} className="[overflow-wrap:anywhere]" data-issuance-sql-check={c.node_id ?? ''}>
<span className="font-medium text-fg">{c.query || RUN_SQL_CHECK_TEXT.issuanceQuery}</span>
{!!c.problems?.length && <span className="text-fg">:{c.problems.join(';')}</span>}
{!!c.refs?.length && <span className="text-dim">({RUN_SQL_CHECK_TEXT.issuanceAffected(c.refs.join('、'))})</span>}
</li>
))}
</ul>
<div className="mt-0.5 text-dim">{RUN_SQL_CHECK_TEXT.issuanceNext}</div>
</div>
)
}
const TIER_META: Record<string, { color: string; soft: string; hint: string }> = {
formal: { color: 'var(--st-done)', soft: 'var(--st-done-soft)', hint: ISSUANCE_HINT.formal },
degraded: { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: ISSUANCE_HINT.degraded },
@@ -2277,7 +2318,12 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
if (!tier) return null
const meta = TIER_META[tier] ?? { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: '' }
const unmatched: any[] = issuance?.unmatched_numbers ?? []
const gaps: string[] = Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []
// 没通过 SQL 检查的查询(出具声明的 sql_checks)单列一块、写在显眼处:数字都有出处,降档却是因为算数的查询本身有问题。
// 单列了的那几句(gap)不在其余缺口里重复
const sqlChecks: IssuanceSqlCheck[] = (Array.isArray(issuance?.sql_checks) ? issuance.sql_checks : [])
.filter((x: unknown): x is IssuanceSqlCheck => !!x && typeof x === 'object')
const sqlGaps = new Set(sqlChecks.map((x) => x.gap).filter(Boolean))
const gaps: string[] = (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g))
const calibers: any[] = issuance?.calibers ?? []
const matched = Number(issuance?.matched_numbers ?? 0)
// 逐个数字的出处(matched[].caliber 形如「口径名 @ v2」):有它就能按卡数清楚,
@@ -2294,6 +2340,8 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
const uncitedClaims = Number(issuance?.claims?.uncited_claims ?? 0) || 0
const numbers = counts ? evidenceTally(counts.cited, counts.total, counts.other) : ''
const numbersOk = !counts?.none && !counts?.other
// 数字都有出处只在完整出具时用绿色:降档时「10 个数字都有出处」画成绿的,读的人会以为一切正常
const numbersColor = !numbersOk ? 'var(--st-waiting)' : tier === 'formal' ? 'var(--st-done)' : 'var(--text-dim)'
// 四期:结论句有判定(开了裁判,或探索运行里按需判过)时多一段「结论 4 句(支持 3 · 无证据 1)」。
// 没开裁判的文档里没挂依据的都数在这一段的「无证据」里,不再另起一句;开了裁判的文档里,没挂依据却送了
// 裁判的句子按判定数、预筛放掉的不数——出具照样按没挂依据计缺口(require_citation、judge 都是),这时
@@ -2325,7 +2373,7 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
// 结论句靠句末徽标定位;只是没挂依据的结论句没有徽标,定位不到
<div className="mt-1 flex flex-wrap items-center gap-2 text-2xs" data-evidence-line="">
{numbers && (
<span className="tnum" style={{ color: numbersOk ? 'var(--st-done)' : 'var(--st-waiting)' }}>{numbers}</span>
<span className="tnum" style={{ color: numbersColor }} data-evidence-line-numbers="">{numbers}</span>
)}
{suspect > 0 && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-line-suspect="">
@@ -2347,9 +2395,10 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
</div>
)}
{!!sqlChecks.length && <IssuanceSqlChecks tier={tier} checks={sqlChecks} />}
{!!gaps.length && (
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>
<div className="font-medium">校验未全部完成:</div>
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-issuance-gaps="">
<div className="font-medium">{RUN_SQL_CHECK_TEXT.otherGaps}</div>
<ul className="mt-0.5 space-y-0.5 pl-2">
{gaps.map((g) => <li key={g}>· {g}</li>)}
</ul>
+13 -4
View File
@@ -212,7 +212,13 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
* (on_demand / inline),没开的看这里,不给就按运行 id 取一次
*/
runClass?: string
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass }, ref) {
/**
* 这次出具的档位(output._issuance.tier)。不是完整出具时,「N 个数字都有出处」不画成绿色:
* 数字的出处都对得上,档位却因为别的原因(比如查询没通过 SQL 检查)降了
*/
tier?: string
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass,
tier }, ref) {
const blocks = useMemo(() => doc.blocks ?? [], [doc])
const report = doc.node_id || undefined
// 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改
@@ -447,7 +453,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
<p id={summaryId} className="sr-only" data-evidence-summary="">{summary}</p>
<div className="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && (
<EvidenceTally counts={{ ...counts, claims }} onNext={() => {
<EvidenceTally counts={{ ...counts, claims }} tier={tier} onNext={() => {
const target = step(model.alerts, current, 1)
if (target) { focusSeg(target, { flash: true }); openSeg(target) } else openViolations()
}} onList={doc.violations?.length ? openViolations : undefined} />
@@ -520,10 +526,12 @@ function usePanelMode(panel: 'auto' | PanelMode, dense: boolean): PanelMode {
* 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」,
* 有结论句判定时接着说「结论 4 句(支持 3 · 不支持 1)」
*/
export function EvidenceTally({ counts, onNext, onList }: {
export function EvidenceTally({ counts, onNext, onList, tier }: {
counts: Pick<Tally, 'total' | 'cited' | 'none' | 'other' | 'suspect' | 'claims'>
onNext?: () => void
onList?: () => void
/** 出具档位:给了、又不是完整出具时,数字都有出处也不用绿色 */
tier?: string
}) {
const suspect = counts.suspect ?? 0
const claims = counts.claims
@@ -532,7 +540,8 @@ export function EvidenceTally({ counts, onNext, onList }: {
return (
<div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs" data-evidence-tally="">
{numbers && (
<span className="tnum" style={{ color: !counts.none && !counts.other ? 'var(--st-done)' : 'var(--st-waiting)' }}>
<span className="tnum" data-evidence-tally-numbers=""
style={{ color: counts.none || counts.other ? 'var(--st-waiting)' : !tier || tier === 'formal' ? 'var(--st-done)' : 'var(--text-dim)' }}>
{numbers}
</span>
)}
+15 -1
View File
@@ -18,7 +18,7 @@ import {
} from '../lib/evidence'
import { humanizeError } from '../lib/errors'
import { formatDateTime, formatNumber, NONE, shortId } from '../lib/format'
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, SQL_CHECK_TEXT, nodeTypeLabel } from '../lib/terms'
import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, nodeTypeLabel } from '../lib/terms'
import { useCatalog } from '../store/catalog'
import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence'
import type {
@@ -1371,6 +1371,13 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
<CopyChip label={step.merge ? MERGE_TEXT.copySql : EVIDENCE_TEXT.copySql} text={() => step.sql ?? ''} />
)}
</div>
{!!step.catalog?.length && (
// SQL 检查对照的是哪一版数据目录(查询当时冻结进表结构快照的那份):事后目录改了、升了版本,看得出这次按的是哪一版
<div className="mb-1 text-2xs text-faint [overflow-wrap:anywhere]" data-ev-catalog=""
title={RUN_SQL_CHECK_TEXT.catalogHint}>
{RUN_SQL_CHECK_TEXT.catalogLine(step.catalog.map((c) => ({ name: c.label || c.table, version: c.version })))}
</div>
)}
{step.merge && <MergeInputs merge={step.merge} />}
{step.sql && step.merge && <div className="mb-0.5 text-2xs text-faint">{MERGE_TEXT.sql}</div>}
{step.sql && (
@@ -1471,6 +1478,13 @@ function MergeInputs({ merge }: { merge: EvidenceMerge }) {
.filter(Boolean).join(' · ')}
</span>
{host.onNode && i.node_id && <NodeChip id={i.node_id} />}
{!!i.checks?.length && (
// 输入查询自己的 SQL 检查:被引用的格追不到逐格来历时,下面没有这个输入的查询步骤,问题只能在这里看到
<div className="mt-0.5 w-full" data-ev-merge-input-checks={i.alias}>
<div className="mb-0.5 text-faint">{RUN_SQL_CHECK_TEXT.mergeInputChecks(i.alias)}</div>
<SqlCheckList checks={i.checks} />
</div>
)}
</li>
))}
</ul>
+63 -2
View File
@@ -188,6 +188,29 @@ export const CALIBERS_OUTPUT = {
},
}
/**
* 数字都有出处,却因为算数的查询没通过 SQL 检查而降档(engine/nodes/io.py 的 sql_checks):横幅单列一块说明,
* 那一句不在其余缺口里重复;「都有出处」不画成绿色
*/
export const SQL_GAP = `查询「订单金额查询」(Q1)未通过 SQL 检查(${'「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'}),结果不可靠;受影响的引用:指标「订单金额」、Q1 第 1 行「gmv」`
export const SQL_DEGRADED_OUTPUT = {
answer: '上周订单金额 **119,160** 元。',
_issuance: {
tier: 'degraded',
calibers: [{ node: 'k1', caliber: '订单口径', version: 'v2' }],
metrics_checked: 1,
missing_required: [],
missing_expected: [],
unmatched_numbers: [],
matched_numbers: 1,
matched: [{ token: '119,160', metric: 'gmv', caliber: '订单口径 @ v2' }],
gaps: [SQL_GAP, '协作团队「分析团队」用完 2 轮仍未完成,交付的是成员最后的回复'],
sql_checks: [{ query: '查询「订单金额查询」(Q1)', node_id: 'q_a',
problems: ['「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'],
refs: ['指标「订单金额」', 'Q1 第 1 行「gmv」'], gap: SQL_GAP }],
},
}
/** 只有一张口径卡、逐个出处都指向它:直说「都来自这张卡」 */
export const CALIBER_ONE_OUTPUT = {
answer: '上周订单 **128** 单,已付款 **96** 单。',
@@ -365,15 +388,53 @@ export function mergeRun(): RunEvent[] {
truncated: false, duration_ms: 12, query_artifact: 'mq-merge', lineage: true,
warnings: [{ code: 'key_type_mismatch', message: '键类型' }, { code: 'rows_grew', message: '行数' }],
}, 0.02)
ev('log', 'merge', { level: 'warn', code: 'merge_key_type',
// 结构化字段和 engine/merge_query._warnings 一致:界面读字段,不从 message 里解析
ev('log', 'merge', { level: 'warn', code: 'merge_key_type', keys: ['s.门店', 'v.门店'],
detail: "s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)",
message: "合并键类型不一致:s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)。SQLite 比较时会做隐式转换:文本形式的编号(如 '001'、'01')都会等于数值 1,也可能完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换" })
ev('log', 'merge', { level: 'warn', code: 'merge_rows_grew',
ev('log', 'merge', { level: 'warn', code: 'merge_rows_grew', rows: 8, input: 's', input_rows: 4,
duplicates: '「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组',
message: '合并结果有 8 行,多于行数最多的输入「s」(4 行):合并键可能不唯一,同一行被重复匹配。「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组。请检查合并条件是否覆盖了全部键(例如同时按日期和门店),或先在源库里聚合到相同粒度' })
ev('node.finished', 'merge', { duration_ms: 15, attempt: 1 })
ev('run.finished', null, { output: {}, timing: { wall_ms: 400, active_ms: 400, wait_ms: 0 } })
return out
}
/**
* 两个查询节点并行、用同一个数据源工具(独立的调用工具节点不带 call_id):时间线要按节点配对,不能按工具名。
* 第一条查询一对多关联后求和,tool.end 带着 SQL 检查结果;口径卡里两个指标取自它,日志按查询归并成一行,
* 带着来源查询节点和它的 SQL 那一栏(engine/nodes/metrics._emit_sql_check_logs)
*/
export const PARALLEL_SQL_A = 'SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id'
export const PARALLEL_SQL_B = 'SELECT COUNT(*) AS refunds FROM refunds'
export const FANOUT_PROBLEM = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'
export const FANOUT_CHECK = { code: 'fanout_sum', level: 'error', table: 'orders', column: 'total_amount',
relation_id: 'r1a2b3c4d5e6', message: `${FANOUT_PROBLEM}。请先按订单汇总明细再关联` }
export function parallelQueryRun(): RunEvent[] {
const { out, ev } = builder('syn-parallel')
ev('run.started', null, { nodes: 4, resumed: false })
ev('node.started', 'q_a', { node_type: 'tool', label: '订单金额查询' })
ev('node.started', 'q_b', { node_type: 'tool', label: '退款查询' })
ev('tool.start', 'q_a', { tool: 'db_query__shop', args: { sql: PARALLEL_SQL_A } })
ev('tool.start', 'q_b', { tool: 'db_query__shop', args: { sql: PARALLEL_SQL_B } })
// 先开始的那条先结束:按工具名配对时,它会配到后开始的那一行上
ev('tool.end', 'q_a', { tool: 'db_query__shop', duration_ms: 40, artifact: 'ts-q_a', query_artifact: 'qs-q_a',
preview: JSON.stringify({ columns: ['gmv'], rows: [[119160]], row_count: 1, truncated: false, sql: PARALLEL_SQL_A, source: 'shop' }),
checks: [FANOUT_CHECK] }, 0.04)
ev('tool.end', 'q_b', { tool: 'db_query__shop', duration_ms: 30, artifact: 'ts-q_b', query_artifact: 'qs-q_b',
preview: JSON.stringify({ columns: ['refunds'], rows: [[7]], row_count: 1, truncated: false, sql: PARALLEL_SQL_B, source: 'shop' }) }, 0.03)
ev('node.finished', 'q_a', { duration_ms: 45, attempt: 1 })
ev('node.finished', 'q_b', { duration_ms: 35, attempt: 1 })
ev('node.started', 'card', { node_type: 'metrics', label: '订单口径' })
ev('log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'gmv', metrics: ['gmv', 'aov'],
metric_names: ['订单金额', '客单价'], reason: `所依据的查询未通过 SQL 检查(${FANOUT_PROBLEM}),结果不可靠`,
problems: [FANOUT_PROBLEM], checks: [FANOUT_CHECK], query_artifact: 'qs-q_a', source_node: 'q_a', source_field: 'args.sql',
message: `指标「订单金额」「客单价」所依据的查询未通过 SQL 检查(${FANOUT_PROBLEM}),结果不可靠` })
ev('node.finished', 'card', { duration_ms: 3, attempt: 1 })
ev('run.finished', null, { output: {}, timing: { wall_ms: 300, active_ms: 300, wait_ms: 0 } })
return out
}
/** 结构化校验:原文里没有数,修复两次都编出了 total_count=0,两次都作废,判失败 */
export function repairRun(): RunEvent[] {
const { out, ev } = builder('syn-repair')
+107 -55
View File
@@ -2,12 +2,13 @@ import type { CatalogDriftTable, RunEvent, SqlCheckItem, TeamMember, TeamRound,
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import {
CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally,
CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL,
claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
// types.ts 里;这里再导出一遍,老引用不用改
@@ -152,6 +153,13 @@ export interface Step {
tier?: string
/** 合并查询那一行(merge.end):合并了哪几个输入、几条警告。SQL 在 detail,预览在 result */
merge?: MergeStep
/** 对照数据目录的 SQL 检查结果:查询那一行(tool.end.checks)、指标所依据的查询没通过检查那一行(log.checks) */
checks?: SqlCheckItem[]
/** fix 要去的不是这一行所在的节点(指标的问题出在来源查询节点的 SQL 上):去哪个节点、落到哪一栏 */
fixNode?: string
fixField?: string
/** fixNode 在画布上的名字(入口写「打开「取数」的 SQL」) */
fixLabel?: string
}
/** 合并查询的一个输入:合并 SQL 里的别名、上游节点、行数、数据源 */
@@ -441,8 +449,10 @@ export interface ResultTable {
rows: unknown[][]
/** 查询本身撞了行数上限——数据库里还有更多,是 guard 没让它全取回来 */
truncated: boolean
/** 这份预览被按字符数切断了——取回来的行数比这里显示的多,只是没存下来 */
/** 这份预览被按字符数切断了,或者本来就只存了前几行——实际的行数比这里显示的多,只是没存下来 */
clipped?: boolean
/** 实际一共几行(结果里的 row_count)。比 rows 多时,表格脚注写「显示前 5 / 6 行」 */
total?: number
}
/**
@@ -477,7 +487,9 @@ function tryTable(text: string): ResultTable | null {
try {
const data = JSON.parse(text)
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
const total = typeof data.row_count === 'number' && data.row_count > data.rows.length ? data.row_count : undefined
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated,
...(data.clipped === true || total != null ? { clipped: true } : {}), ...(total != null ? { total } : {}) }
}
} catch {
/* 交给调用方决定要不要修 */
@@ -704,7 +716,10 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason:
* 贴出来是一串标记和术语,而且不说该去哪儿改——这里说成人话,并给出下一步。
* 认不得的 code 返回 null,照原话显示
*/
function explainLog(code: string | undefined, message: string): Pick<Step, 'title' | 'sub' | 'next' | 'fix'> | null {
function explainLog(code: string | undefined, message: string, d: Record<string, any> = {},
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks'> | null {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
switch (code) {
case 'tool_markup_leak': {
// 「数据查询把工具调用写成了文字(<||DSML||…),没有真正调用工具,已提醒它重试一次」
@@ -770,28 +785,34 @@ function explainLog(code: string | undefined, message: string): Pick<Step, 'titl
}
}
case 'metric_incomplete': {
// 「指标「入园人次」基于被截断的查询结果计算(只取回了前 1000 行),结果不完整」(metrics.py _incomplete_reason)
const name = message.match(/^指标「(.+?)」/)?.[1]
// 结构化字段(metrics.py):metric_name、reason(「基于被截断的查询结果计算(只取回了前 1000 行),结果不完整」)。
// 老运行的日志没有这两个字段:只给不点名的标题,原话留在展开区,不从中文里解析
const name = str(d.metric_name)
const reason = str(d.reason)?.replace(/,结果不完整$/, '')
return {
title: name ? `指标「${name}」结果不完整` : '指标结果不完整',
sub: message.match(/^指标「.+?」(.+?),结果不完整$/)?.[1],
sub: reason,
next: '口径卡对截断的查询结果计数、求和,只算到了取回的部分,出具按缺口降档。'
+ '请在 SQL 中直接聚合(如 COUNT、SUM)或缩小查询范围后重新运行',
fix: 'canvas',
}
}
case 'metric_sql_check': {
// 「指标「客单价」所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会
// 重复计算),结果不可靠」;几处时是「所依据的查询有 2 处未通过 SQL 检查(…等),结果不可靠」(metrics.py
// _sql_check_reason)。括号里的问题本身可能带括号:取到「,结果不可靠」前面最后一个右括号
const name = message.match(/^指标「(.+?)」/)?.[1]
const n = message.match(/有\s*(\d+)\s*处未通过 SQL 检查/)?.[1]
const why = message.match(/未通过 SQL 检查(([\s\S]+))[,,]\s*结果不可靠\s*$/)?.[1]
// 一条没通过 SQL 检查的来源查询一行(metrics.py _emit_sql_check_logs),结构化字段:metric_names(受影响的指标)、
// problems(问题那半句)、checks(级别、规则、表和列)、source_node / source_field(来源查询节点和它的 SQL 那一栏)。
// 「打开设置」落到来源查询节点的 SQL 上:要改的是那条 SQL,不是口径卡。老运行没有这些字段:只给不点名的标题
const names = strs(d.metric_names)
const problems = strs(d.problems)
const checks = sqlChecksOf(d.checks)
const source = str(d.source_node)
return {
title: name ? SQL_CHECK_TEXT.metricTitle(name, n ? Number(n) : 1) : SQL_CHECK_TEXT.metricTitleAnon,
sub: why,
next: SQL_CHECK_TEXT.metricNext,
title: names.length ? RUN_SQL_CHECK_TEXT.metricsTitle(names, Math.max(1, problems.length)) : SQL_CHECK_TEXT.metricTitleAnon,
sub: problems.join(';') || undefined,
next: source ? RUN_SQL_CHECK_TEXT.metricNext : SQL_CHECK_TEXT.metricNext,
fix: 'canvas',
...(source ? { fixNode: source } : {}),
...(source && str(d.source_field) ? { fixField: str(d.source_field) } : {}),
...(checks.length ? { checks } : {}),
}
}
case 'judge_limit': {
@@ -805,33 +826,33 @@ function explainLog(code: string | undefined, message: string): Pick<Step, 'titl
}
}
case 'merge_key_type': {
// 「合并键类型不一致:s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)。SQLite 比较时会做隐式转换…」
// (engine/merge_query._warnings)。标题说是哪两列,副标题说各是什么类型
const m = message.match(/^合并键类型不一致[::]\s*(.+?)。/)
const keys = [...(m?.[1] ?? '').matchAll(/([\w一-龥]+\.[^\s,,]+)\s*是/g)].map((x) => x[1])
// 结构化字段(engine/merge_query._warnings):keys(两边的列)、detail(「s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)」)
const keys = strs(d.keys)
return {
title: keys.length === 2 ? `合并键类型不一致:${keys[0]} 与 ${keys[1]}` : '合并键类型不一致',
sub: m?.[1],
sub: str(d.detail),
next: '两边类型不同时,SQLite 按隐式转换比较,可能错配或完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换',
fix: 'canvas',
}
}
case 'merge_rows_grew': {
// 「合并结果有 8 行,多于行数最多的输入「s」(4 行):合并键可能不唯一,同一行被重复匹配。…」
const head = message.match(/^合并结果有\s*([\d,]+)\s*行,多于行数最多的输入「(.+?)」(([\d,]+)\s*行)/)
const dup = message.match(/重复匹配。(.+?)。请检查/)?.[1]
// 结构化字段:rows(合并结果行数)、input / input_rows(行数最多的输入)、duplicates(各输入里重复的键)
const rows = num(d.rows)
const input = str(d.input)
const inputRows = num(d.input_rows)
return {
title: '合并结果行数多于输入,合并键可能不唯一',
sub: [head ? `合并结果 ${head[1]} 行,行数最多的输入「${head[2]}」${head[3]} 行` : '', dup ?? '']
.filter(Boolean).join(';') || undefined,
sub: [rows != null && input && inputRows != null
? `合并结果 ${formatNumber(rows)} 行,行数最多的输入「${input}」${formatNumber(inputRows)} 行` : '',
str(d.duplicates) ?? ''].filter(Boolean).join(';') || undefined,
next: '请检查合并条件是否覆盖了全部合并键(例如同时按日期和门店),或先在源库里聚合到相同粒度',
fix: 'canvas',
}
}
case 'merge_truncated': {
const rows = message.match(/只保留了前\s*([\d,]+)\s*行/)?.[1]
const rows = num(d.rows)
return {
title: `合并结果超过上限,${rows ? `只保留了前 ${rows} 行` : '已截断'}`,
title: `合并结果超过上限,${rows != null ? `只保留了前 ${formatNumber(rows)} 行` : '已截断'}`,
next: '下游拿到的不是完整结果。请在合并 SQL 中聚合或加条件缩小范围',
fix: 'canvas',
}
@@ -895,6 +916,12 @@ function explainLog(code: string | undefined, message: string): Pick<Step, 'titl
}
}
/** tool.start / tool.end 的配对键:有 call_id 用它;独立的调用工具节点不带 call_id,用「节点 + 工具名」 */
function toolKey(nodeId: string | undefined, d: Record<string, any>, seq?: number): string {
if (d.call_id) return `call:${String(d.call_id)}`
return `${nodeId ?? '_'}:${String(d.tool || (seq ?? ''))}`
}
function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
@@ -1629,15 +1656,16 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
// 协作成员调的工具说清是谁调的
if (d.agent) step.sub = `${d.agent} 调用`
// call_id 才是可靠的配对键:同一节点并发调同名工具时,按名字配会错位。
// 独立 Tool 节点不带 call_id,回退到工具名。
pendingTools.set(String(d.call_id || d.tool || seq), step)
// 独立 Tool 节点不带 call_id,回退到「节点 + 工具名」:两个并行的查询节点用同一个工具时,
// 只按工具名配,后开始的那条会顶掉先开始的,结果配错节点
pendingTools.set(toolKey(nodeId, d, seq), step)
push(step, nodeId)
break
}
case 'tool.end':
case 'tool.error': {
const key = String(d.call_id || d.tool || '')
const key = toolKey(nodeId, d)
const step = pendingTools.get(key)
const preview = String(d.preview ?? d.error ?? '')
if (step) {
@@ -1662,6 +1690,16 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
step.meta = parts.join(' · ') || undefined
step.artifact = d.artifact
if (typeof d.detail === 'string' && d.detail) step.raw = d.detail
// 这次查询对照数据目录的 SQL 检查(tool.end.checks):preview 只是结果的前一截,检查结果通常被截掉,单独带着。
// 副标题数一下错误、提醒、提示,展开看每一条
const checks = sqlChecksOf(d.checks)
if (checks.length && !failed) {
step.checks = sortSqlChecks(checks)
const n = (lv: string) => checks.filter((c) => c.level === lv).length
const counts = SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n('error'), n('warning'), n('info')))
step.sub = step.sub ? `${step.sub} · ${counts}` : counts
if (n('error') || n('warning')) step.level = 'warn'
}
if (step.kind === 'query' || step.kind === 'schema' || failed) {
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
@@ -2051,7 +2089,7 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
// 其余 info 是给排查用的,不进主流程——但 warn/error 用户必须看到
if (level === 'info' || !message) break
const code = typeof d.code === 'string' && d.code ? d.code : undefined
const said = explainLog(code, message)
const said = explainLog(code, message, d)
if (code === 'repair_invented') {
// 修复编出了原文没有的值:折进刚才那一行「让模型修复格式」,一件事一行
const repair = repairs.get(nodeId ?? '_')
@@ -2063,12 +2101,14 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
}
}
if (code === 'team_exhausted') trackTeam(event)
// 要改的是另一个节点(来源查询):入口写它的名字,认不出就只写「打开来源查询的 SQL」
const fixLabel = said?.fixNode ? nodeSteps.get(said.fixNode)?.title : undefined
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done',
level: level === 'error' ? 'error' : 'warn',
...(code ? { code } : {}),
...(said
? { ...said, detail: message }
? { ...said, detail: message, ...(fixLabel ? { fixLabel } : {}) }
: { title: clip(message, 120), ...(message.length > 120 ? { detail: message } : {}) }),
}, nodeId)
break
@@ -2187,8 +2227,11 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
...(warnings ? { level: 'warn' as const, sub: MERGE_TEXT.warnings(warnings) } : {}),
title: MERGE_TEXT.stepTitle(inputs.map((i) => i.alias)),
detail: typeof d.sql === 'string' && d.sql ? d.sql : undefined,
// 预览只存了前几行:那是「此处仅为预览」(clipped),不是「查询已达行数上限」(truncated)。
// 合并结果本身超过上限才是 truncated;row_count 记全部行数,表格脚注写「显示前 5 / 6 行」
result: Array.isArray(d.columns)
? JSON.stringify({ columns: d.columns, rows: preview, truncated: rows != null && rows > preview.length })
? JSON.stringify({ columns: d.columns, rows: preview, truncated: !!d.truncated,
clipped: rows != null && rows > preview.length, ...(rows != null ? { row_count: rows } : {}) })
: undefined,
meta: [rows != null ? MERGE_TEXT.rows(rows) : '', dur(ms) ?? ''].filter(Boolean).join(' · ') || undefined,
ms,
@@ -2472,10 +2515,8 @@ export interface CopilotIssue {
code?: string
/** error / warning / info。老会话、老后端不给 */
level?: 'error' | 'warning' | 'info'
/** SQL 检查的问题另带:涉及的表和列、SQL 片段 */
table?: string
column?: string
sqlExcerpt?: string
/** SQL 检查的问题按检查读出来的那一条(lib/sqlcheck 的 sqlCheckOf:级别、规则、表和列、关系编号、SQL 片段) */
sql?: SqlCheckItem
}
/**
@@ -2491,15 +2532,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
// SQL 检查和证据面板、运行时间线同一个读法(sqlCheckOf,关系编号也留着)。自查交回模型改的问题老后端不给级别,
// 那时交回去的都是错误:没给级别的按错误读
const sql = isSqlCheckCode(o.code) ? sqlCheckOf({ ...o, level: o.level ?? 'error' }) : null
return named({
message: String(o.message ?? ''),
...(o.node_id ? { nodeId: String(o.node_id) } : {}),
...(str(o.field) ? { field: str(o.field) } : {}),
...(str(o.code) ? { code: str(o.code) } : {}),
...(o.level === 'error' || o.level === 'warning' || o.level === 'info' ? { level: o.level } : {}),
...(str(o.table) ? { table: str(o.table) } : {}),
...(str(o.column) ? { column: str(o.column) } : {}),
...(str(o.sql_excerpt) ? { sqlExcerpt: str(o.sql_excerpt) } : {}),
...(sql ? { sql } : {}),
})
}
const text = String(v ?? '')
@@ -2512,20 +2554,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
* 不露规则编号
*/
export const issueLine = (x: CopilotIssue): string => {
const where = isSqlCheckCode(x.code) ? sqlCheckWhere({ table: x.table ?? '', column: x.column }) : ''
const body = isSqlCheckCode(x.code)
? `${sqlRuleLabel(x.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
const where = x.sql ? sqlCheckWhere(x.sql) : ''
const body = x.sql
? `${sqlRuleLabel(x.sql.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
: x.message
return x.nodeId ? `「${x.label ?? x.nodeId}」${body}` : body
}
/** 一条自查问题按 SQL 检查读(界面上画级别、规则名、表和列、SQL 片段);不是 SQL 检查的返回 null */
export function issueSqlCheck(x: CopilotIssue): SqlCheckItem | null {
if (!isSqlCheckCode(x.code)) return null
return {
code: x.code, level: x.level ?? 'error', message: x.message, table: x.table ?? '',
...(x.column ? { column: x.column } : {}), ...(x.sqlExcerpt ? { sql_excerpt: x.sqlExcerpt } : {}),
}
return x.sql ?? null
}
/** final.issues 里对照数据目录的 SQL 检查结果(不挡运行的提醒、提示也在里面);自查没修好、已经列过的错误不再算 */
@@ -2665,26 +2703,40 @@ function contextLine(g: CopilotContextSource): string {
* 标题直说只给了表名,原因放在展开区。认不出的形状返回 null:不出这一行,也不报错。
*/
export function copilotContext(op: CopilotOp): { title: string; sub?: string; detail: string; ms?: number } | null {
const groups = contextSources(op)
// 展开区里按需求挑过表的库排在前面:请求里点名的那个库往往就是它,排在一串小库后面得在内层滚动框里翻才看得到
const order: Record<CopilotContextSource['selectedBy'], number> = { model: 0, all: 1, fallback: 2 }
const groups = contextSources(op).map((g, i) => ({ g, i }))
.sort((a, b) => order[a.g.selectedBy] - order[b.g.selectedBy] || a.i - b.i).map((x) => x.g)
if (!groups.length) return null
// 两个数分开说:带着全部字段的表(count),和只提供了表名的表(namesOnly:没挑成的库、一张没挑中的库)。
// 以前标题只写前者,「参考了 26 张表」看不出另有 206 张只给了表名
const count = groups.reduce((n, g) => n + g.tables.length, 0)
const namesOnly = groups.reduce((n, g) => n + namesOnlyOf(g), 0)
const failed = groups.filter((g) => g.selectedBy === 'fallback')
// 有表带着字段(小库,或者现有工作流用到的表)时标题仍说几张,没挑成的事放副标题
const who = failed.length && failed.length < groups.length ? `「${failed.map((g) => g.source).join('」「')}」` : ''
const sub = count && failed.length
? failed.length === groups.length ? CONTEXT_FALLBACK_TEXT
: `「${failed.map((g) => g.source).join('」「')}」${CONTEXT_FALLBACK_TEXT}`
: undefined
? namesOnly ? `${who}${COPILOT_CONTEXT_TEXT.notPicked},${COPILOT_CONTEXT_TEXT.namesOnly(namesOnly)}` : `${who}${CONTEXT_FALLBACK_TEXT}`
: count && namesOnly ? COPILOT_CONTEXT_TEXT.namesOnly(namesOnly) : undefined
const reasons = [...new Set(failed.map((g) => g.reason).filter(Boolean))]
// 挑过表才有 elapsed_ms:挑表是生成之前多出来的一次模型调用,没挑成(比如超时)也要让人看到等了多久
const ms = num(op.elapsed_ms)
return {
title: count ? `参考了 ${count} 张表` : CONTEXT_FALLBACK_TEXT,
title: count ? (namesOnly ? COPILOT_CONTEXT_TEXT.withFields(count) : `参考了 ${count} 张表`) : CONTEXT_FALLBACK_TEXT,
...(sub ? { sub } : {}),
detail: [...groups.map(contextLine), ...reasons.map((r) => `原因:${r}`)].join('\n'),
...(ms != null && ms >= 10 ? { ms } : {}),
}
}
/** 这个库里只提供了表名、没带字段的表有几张(模型看得到的那些:探查截断了的按探查到的算) */
function namesOnlyOf(g: CopilotContextSource): number {
const seen = g.explored ?? g.total
if (seen == null) return 0
if (g.selectedBy === 'fallback' || (g.selectedBy === 'model' && !g.tables.length)) return Math.max(0, seen - g.tables.length)
return 0
}
/** 用了限定范围之外的数据源:自查交回模型改也改不掉时,人要知道是范围的事,不是图写错了 */
const OUT_OF_SCOPE_NEXT = '本轮限定了查询的数据源,但工作流使用了范围之外的数据库。请取消限定后重新提问,或将所需数据库加入查询范围'
+47 -2
View File
@@ -2108,6 +2108,32 @@ export interface EvidenceMergeInput {
source?: string | null
artifact?: string | null
sealed?: boolean
/** 这个输入查询对照数据目录查出的问题(形状同查询步骤的 checks)。没查出问题、快照读不出来时没有这个键 */
checks?: SqlCheckItem[]
}
/**
* 出具声明(output._issuance、issuance 事件)里的 sql_checks:没通过 SQL 检查的查询,一条一个。报告页、出具横幅据此
* 在显眼处说「因为 SQL 检查没通过而降档」。gap 是 gaps 里对应的那一句(单列之后不在其余缺口里重复)
*/
export interface IssuanceSqlCheck {
/** 「查询「取数」(Q1)」;给不出时没有 */
query?: string
node_id?: string
/** 每条问题那半句 */
problems?: string[]
/** 受影响的引用:「指标「订单金额」」「Q1 第 1 行「gmv」」 */
refs?: string[]
gap?: string
}
/** 查询用到的一张表、查询当时它的数据目录版本 */
export interface EvidenceCatalogVersion {
/** 表结构里的表名 */
table: string
/** 目录里的中文名 */
label?: string
version?: number | null
}
/** 被引用的一格追到了哪个输入的哪一格;追不到时 input 为空,note 说明只有表级来历 */
@@ -2166,6 +2192,10 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
sql_check_reason?: string
/** query:这次查询对照数据目录查出的问题。没查出问题时没有这个键 */
checks?: SqlCheckItem[]
/** query:查询当时冻结的表结构快照(工件 id)。合并查询、老快照没有 */
schema_artifact?: string
/** query:这条查询用到的表在表结构快照里冻结的数据目录版本(SQL 检查对照的那一版)。没有目录时没有这个键 */
catalog?: EvidenceCatalogVersion[]
inputs?: EvidenceInput[]
/** 口径卡钉在哪个工作流的哪一版(metric;方案第 5 节的写法,接口实际给在 source 上) */
caliber_from?: EvidenceCaliberSource | null
@@ -2622,6 +2652,11 @@ export interface CatalogRelation {
status: CatalogStatus
note?: string
updated_at?: string
/**
* 基数是数据剖析用数据核实的(子表一侧数过是否唯一),只由剖析写。外键、命名推断的基数没有这个键。
* 一对多关联后重复计算的检查只在它为真、或者关系人工确认过时报「错误」,否则最多是「提醒」
*/
cardinality_checked?: boolean
}
/** 一张表的目录(notes)。字段固定,服务端不认识的一律拒收 */
@@ -2731,8 +2766,13 @@ export interface CatalogDraftRow {
version: number
/** 这张表没有起草(表结构里没有、写入一直冲突) */
error: string | null
/** 只是模型那部分失败,其余来源照常写入 */
/** 只是模型那部分失败,其余来源照常写入。模型给了这张表、却一项可用内容都没有,也记在这里 */
model_error: string | null
/**
* 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null(老后端没有这个键)。
* 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
*/
model_items?: number | null
}
/** POST /datasources/{id}/catalog/draft */
@@ -2922,6 +2962,11 @@ export interface CatalogProfileOut {
stopped: CatalogProfileStop | null
tables: CatalogProfileTable[]
total: { added: number; updated: number; removed: number }
/**
* 不指定表时,挑表途中估过行数、确知是空表而跳过的表(老后端没有这个键;指定了表时是空列表)。
* 估行数的查询算在 queries_used 里,不算在各表的 queries 里
*/
empty_tables?: string[]
/** 一张表都没有剖析时的说明 */
note: string | null
}
@@ -2933,7 +2978,7 @@ export interface CatalogProfileOut {
export type SqlCheckCode = 'fanout_sum' | 'stock_summed' | 'join_unconfirmed' | 'ratio_aggregated' | 'missing_valid_filter'
| 'unknown_code' | 'wrong_date_column'
/** error:依据有确证、结果必然有误;warning:依据有确证、很可能有误;info:依据只是推断 */
/** error:依据已核实、结果很可能有误;warning:依据有确证、结果可能有误;info:依据只是推断 */
export type SqlCheckLevel = 'error' | 'warning' | 'info'
/** 一条检查结果(证据接口查询步骤里的 checks、助手自查和发布前检查的问题)。交回模型改写用的那句不上界面,这里不收 */
+91 -36
View File
@@ -123,17 +123,29 @@ await section('合并查询', async () => {
check('标题说合并的是哪几个输入', m?.title === '合并 s、v 的查询结果', m?.title)
check('合并 SQL 留在详情里', m?.detail === synthetic.MERGE_SQL)
check('预览按查询结果的形状给出,能画成表格', !!mod.parseQueryResult(m?.result ?? '')?.columns?.includes('到店人数'))
// 合并结果 8 行、预览只存了 5 行:这是「此处仅为预览」,不是「查询已达行数上限」(A3)
const table = mod.parseQueryResult(m?.result ?? '')
check('预览只存了前几行:记为预览(clipped),不记为查询撞了行数上限(truncated),并带上一共几行', table?.truncated === false
&& table?.clipped === true && table?.total === 8 && table?.rows.length === 5, JSON.stringify({ t: table?.truncated, c: table?.clipped, n: table?.total }))
check('输入、行数、数据源都在', m?.merge?.inputs?.length === 2 && m.merge.inputs[1].source === 'members'
&& m.merge.inputs[0].label === '门店销售' && m.merge.rows === 8)
check('行数和耗时写在行尾', !!m?.meta?.includes('8 行'), m?.meta)
check('工件可下钻到合并结果的快照', m?.artifact === 'mq-merge')
check('有警告时这一行标成警告、说有几条', m?.level === 'warn' && !!m.sub?.includes('2 条警告'), m?.sub)
const key = all.find((s) => s.code === 'merge_key_type')
check('键类型不一致说成人话:标题点名两列', !!key?.title.includes('s.门店') && key.title.includes('v.门店'), key?.title)
check('键类型不一致说成人话:标题点名两列(读 keys 字段)', key?.title === '合并键类型不一致:s.门店 与 v.门店', key?.title)
check('……副标题是 detail 字段', key?.sub === "s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)", key?.sub)
check('键类型不一致给出下一步', !!key?.next?.includes('CAST') && key.fix === 'canvas')
const grew = all.find((s) => s.code === 'merge_rows_grew')
check('行数放大说成人话,并带上行数和重复的键', !!grew?.title.includes('不唯一') && !!grew.sub?.includes('8 行')
&& !!grew.sub?.includes('出现 2 次'), grew?.sub)
check('行数放大说成人话,并带上行数和重复的键(读 rows、input、input_rows、duplicates 字段)', !!grew?.title.includes('不唯一')
&& grew.sub === '合并结果 8 行,行数最多的输入「s」4 行;「s」中按(门店)有重复的键,例如 01 出现 2 次等 2 组;「v」中按(门店)有重复的键,例如 1 出现 2 次等 2 组',
grew?.sub)
// 老运行的日志没有结构化字段:不从中文里解析,只给标题,原话留在展开区
const old = flatten(mod.decodeRun([
{ seq: 1, type: 'node.started', node_id: 'merge', ts: 1, data: { node_type: 'merge' } },
{ seq: 2, type: 'log', node_id: 'merge', ts: 2, data: { level: 'warn', code: 'merge_key_type', message: '合并键类型不一致:s.门店 是文本,v.门店 是数值。' } },
])).find((s) => s.code === 'merge_key_type')
check('老日志没有结构化字段:只给标题,不解析原话', old?.title === '合并键类型不一致' && !old.sub && !!old.detail?.includes('s.门店'), old?.title)
check('警告原文各占一行,不在合并那一行里重复', all.filter((s) => s.level === 'warn' && s.nodeId === 'merge').length === 3)
check('合并节点照常完成,两次源查询仍是普通查询步骤', all.filter((s) => s.kind === 'query' && s.code !== 'merge').length === 2
&& steps.find((s) => s.nodeId === 'merge')?.status === 'done')
@@ -673,18 +685,30 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
const seeded = mod.copilotContext({ op: 'context', sources: [
{ source: 'scenic', tables: ['stores'], selected_by: 'fallback', total: 51, reason: '模型未挑出任何表' }] })
check('没挑成但现有工作流的表带着字段:标题说几张、副标题说没挑成',
seeded?.title === '参考了 1 张表' && seeded?.sub === '未能按需求挑选,已提供全部表名'
check('没挑成但现有工作流的表带着字段:标题说几张带字段、副标题说没挑成和只给了表名的张数',
seeded?.title === '参考了 1 张表的字段' && seeded?.sub === '未能按需求挑选,另有 50 张表只提供了表名'
&& seeded.detail.includes('现有工作流用到的 1 张表附带全部字段:stores'), JSON.stringify(seeded))
const mixed = mod.copilotContext({ op: 'context', sources: [
{ source: 'a', tables: ['x'], selected_by: 'all', total: 1 },
{ source: 'b', tables: [], selected_by: 'fallback', total: 80, reason: 'r' }] })
check('只有一个库没挑成:副标题点名是哪个库', mixed?.sub === '「b」未能按需求挑选,已提供全部表名', mixed?.sub)
check('只有一个库没挑成:副标题点名是哪个库,并写只提供了表名的张数', mixed?.title === '参考了 1 张表的字段'
&& mixed?.sub === '「b」未能按需求挑选,另有 80 张表只提供了表名', `${mixed?.title} / ${mixed?.sub}`)
const none = mod.copilotContext({ op: 'context', sources: [
{ source: 'a', tables: ['x'], selected_by: 'model', total: 60 },
{ source: 'b', tables: [], selected_by: 'model', total: 70 }] })
check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名'),
none?.detail)
check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名')
&& none.title === '参考了 1 张表的字段' && none.sub === '另有 70 张表只提供了表名', `${none?.title} / ${none?.sub} / ${none?.detail}`)
// R2 走查(B14):几个小库全给、点名的大库按需求挑、另一个大库没挑成。展开后挑表的库排在最前面,
// 两个数分开说:26 张带字段,206 张只给了表名
const r2 = mod.copilotContext({ op: 'context', sources: [
{ source: 'small_a', tables: Array.from({ length: 12 }, (_, i) => `a${i}`), selected_by: 'all', total: 12 },
{ source: 'small_b', tables: Array.from({ length: 11 }, (_, i) => `b${i}`), selected_by: 'all', total: 11 },
{ source: 'big', tables: [], selected_by: 'fallback', total: 206, reason: '超时' },
{ source: 'named', tables: ['visits', 'channels', 'parks'], selected_by: 'model', total: 51 }] })
check('展开后按需求挑表的库排在第一行,没挑成的排在最后', r2?.detail.split('\n')[0].startsWith('「named」按需求从 51 张表中挑出 3 张')
&& r2.detail.split('\n')[3].startsWith('「big」'), r2?.detail.split('\n').map((l) => l.slice(0, 12)).join(' | '))
check('两个数分开说:带字段的 26 张、只提供了表名的 206 张', r2?.title === '参考了 26 张表的字段'
&& r2.sub === '「big」未能按需求挑选,另有 206 张表只提供了表名', `${r2?.title} / ${r2?.sub}`)
// 探查结构截断了(每个数据源最多取 200 张表):total 是数据库里的总数,explored 是探查到的
const partial = mod.copilotContext({ op: 'context', sources: [
{ source: 'wide', tables: ['t001', 't002'], selected_by: 'model', total: 205, explored: 200 },
@@ -1468,16 +1492,17 @@ await section('agent 字段按出处核对(可点击证据第二期):抽
await section('口径卡指标不完整(数据目录阶段 0):截断的查询结果整组拿来算的指标,警告说人话并指到 SQL', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
// 原话和 backend/app/engine/nodes/metrics.py 的 _incomplete_reason 同一个句式。先核对后端源码还是这么说的
const INCOMPLETE = '指标「入园人次」基于被截断的查询结果计算(只取回了前 1000 行),结果不完整'
// 日志带着结构化字段(metric_name、reason),界面读字段,不从原话里解析。先核对后端还发这两个字段
const REASON = '基于被截断的查询结果计算(只取回了前 1000 行),结果不完整'
const INCOMPLETE = `指标「入园人次」${REASON}`
const metricsPy = readFileSync(`${root}backend/app/engine/nodes/metrics.py`, 'utf8')
check('后端发指标不完整警告的格式还是夹具里这一种', metricsPy.includes('code="metric_incomplete"')
&& metricsPy.includes('message=f"指标「{m[\'name\']}」{m[\'incomplete_reason\']}"')
&& metricsPy.includes('f"基于被截断的查询结果计算(只取回了{fetched[0]}),结果不完整"'),
'改了 metrics.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
check('后端发指标不完整警告时带着 metric_name、reason 字段', metricsPy.includes('code="metric_incomplete"')
&& metricsPy.includes('metric_name=m["name"]') && metricsPy.includes('reason=m["incomplete_reason"]'),
'改了 metrics.py 的字段就同步改 decode.ts 的 explainLog 和这里的夹具')
const all = flatten(mod.decodeRun([
E(1, 'node.started', 'card', { node_type: 'metrics' }),
E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', message: INCOMPLETE }),
E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', metric_name: '入园人次', reason: REASON,
rows: [1000], message: INCOMPLETE }),
E(3, 'node.finished', 'card', { duration_ms: 3 }),
]))
const inc = all.find((s) => s.code === 'metric_incomplete')
@@ -1486,6 +1511,12 @@ await section('口径卡指标不完整(数据目录阶段 0):截断的查
`${inc?.sub} / ${inc?.detail}`)
check('……是提醒(warn),下一步指到 SQL,节点本身照样成功', inc?.level === 'warn' && !!inc?.next?.includes('SQL')
&& all.find((s) => s.kind === 'node')?.status === 'done', `${inc?.level} ${inc?.next}`)
const bare = flatten(mod.decodeRun([
E(1, 'node.started', 'card', { node_type: 'metrics' }),
E(2, 'log', 'card', { level: 'warn', code: 'metric_incomplete', metric: 'visits', message: INCOMPLETE }),
])).find((s) => s.code === 'metric_incomplete')
check('老日志没有结构化字段:只给不点名的标题,不解析原话', bare?.title === '指标结果不完整' && !bare.sub && bare.detail === INCOMPLETE,
bare?.title)
})
await section('结论句裁判(可点击证据第四期):裁判调用单独成行、几条警告说人话、封存后按需裁判有记录', async () => {
@@ -1765,45 +1796,44 @@ await section('按后端原文匹配的地方:后端现在的原文、整改
await section('SQL 检查(数据目录阶段 4B):指标所依据的查询未通过检查说成一行;助手自查和交付的问题写中文规则名', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
// 原话和 backend/app/engine/nodes/metrics.py 的 _sql_check_reason 同一个句式。先核对后端源码还是这么说的
// 日志按查询归并(同一条查询的问题一行),带着结构化字段:metric_names、problems、checks、source_node、source_field。
// 界面读字段,不从原话里解析。先核对后端还发这些字段
const metricsPy = readFileSync(`${root}backend/app/engine/nodes/metrics.py`, 'utf8')
check('后端发指标未通过 SQL 检查警告的格式还是夹具里这几种', metricsPy.includes('code="metric_sql_check"')
&& metricsPy.includes('message=f"指标「{m[\'name\']}」{m[\'sql_check_reason\']}"')
&& metricsPy.includes('"所依据的查询未通过 SQL 检查,结果不可靠"')
&& metricsPy.includes('f"所依据的查询未通过 SQL 检查({problems[0]}),结果不可靠"')
&& metricsPy.includes('f"所依据的查询有 {len(problems)} 处未通过 SQL 检查({problems[0]}等),结果不可靠"'),
'改了 metrics.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
check('后端发指标未通过 SQL 检查的日志时带着结构化字段', metricsPy.includes('code="metric_sql_check"')
&& ['metrics=ids', 'metric_names=', 'problems=problem.problems', 'checks=', 'source_node', 'source_field'].every((k) => metricsPy.includes(k)),
'改了 metrics.py 的字段就同步改 decode.ts 的 explainLog 和这里的夹具')
const CODES = /fanout_sum|stock_summed|join_unconfirmed|ratio_aggregated|missing_valid_filter|unknown_code|wrong_date_column/
const decodeOne = (message) => {
const decodeOne = (fields) => {
const all = flatten(mod.decodeRun([
E(1, 'node.started', 'card', { node_type: 'metrics' }),
E(2, 'log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'aov', message }),
E(2, 'log', 'card', { level: 'warn', code: 'metric_sql_check', metric: 'aov', ...fields }),
E(3, 'node.finished', 'card', { duration_ms: 3 }),
]))
return { step: all.find((s) => s.code === 'metric_sql_check'), node: all.find((s) => s.kind === 'node') }
}
// 问题本身带括号(列的中文名里写了单位):副标题要取到「,结果不可靠」前面最后一个右括号
const ONE = '指标「客单价」所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算),结果不可靠'
const one = decodeOne(ONE)
// 问题本身带括号(列的中文名里写了单位):读字段就不存在截断的问题
const P1 = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算'
const ONE = `指标「客单价」所依据的查询未通过 SQL 检查(${P1}),结果不可靠`
const one = decodeOne({ metric_names: ['客单价'], problems: [P1], message: ONE })
check('metric_sql_check 一行:标题点名哪个指标', one.step?.title === '指标「客单价」所依据的查询未通过 SQL 检查', one.step?.title)
check('……原因放副标题(问题里的括号不截断),原话留在展开区',
one.step?.sub === '「订单」关联「订单明细」是一对多,对「订单」的「订单金额(元)」求和会重复计算' && one.step?.detail === ONE,
check('……原因放副标题(读 problems 字段),原话留在展开区', one.step?.sub === P1 && one.step?.detail === ONE,
`${one.step?.sub} / ${one.step?.detail}`)
check('……是提醒(warn),下一步指到 SQL 和数据目录,节点本身照样成功', one.step?.level === 'warn'
&& !!one.step?.next?.includes('SQL') && !!one.step?.next?.includes('数据目录') && one.step?.fix === 'canvas'
&& one.node?.status === 'done', `${one.step?.level} ${one.step?.next}`)
const two = decodeOne('指标「客单价」所依据的查询有 2 处未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算等),结果不可靠')
check('几处问题:标题写处数,副标题是第一处', two.step?.title === '指标「客单价」所依据的查询有 2 处未通过 SQL 检查'
&& two.step?.sub === '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算等', `${two.step?.title} / ${two.step?.sub}`)
const bare = decodeOne('指标「客单价」所依据的查询未通过 SQL 检查,结果不可靠')
check('没有括号里的问题:只有标题,不编副标题', bare.step?.title === '指标「客单价」所依据的查询未通过 SQL 检查' && !bare.step?.sub,
`${bare.step?.title} / ${bare.step?.sub}`)
const P2 = '「库存」按日期以外的列求和会把存量跨期加总'
const two = decodeOne({ metric_names: ['客单价', '订单金额'], problems: [P1, P2], message: 'x' })
check('一条查询两处问题、影响两个指标:标题点名两个指标并写处数,副标题列出两处', two.step?.title === '指标「客单价」「订单金额」所依据的查询有 2 处未通过 SQL 检查'
&& two.step?.sub === `${P1};${P2}`, `${two.step?.title} / ${two.step?.sub}`)
const bare = decodeOne({ message: '指标「客单价」所依据的查询未通过 SQL 检查(某个问题),结果不可靠' })
check('老日志没有结构化字段:只给不点名的标题,不解析原话', bare.step?.title === '指标所依据的查询未通过 SQL 检查' && !bare.step?.sub
&& !bare.step?.fixNode, `${bare.step?.title} / ${bare.step?.sub}`)
// 助手自查(check)和交付(final.issues)里的 SQL 检查问题:code 是规则编号,field 是 args.sql,另带表、列和 SQL 片段
const sqlIssue = (level, code, message, extra = {}) => ({ level, node_id: 'q', edge_id: null, field: 'args.sql', code, message,
table: 'orders', ...extra })
const ERR = sqlIssue('error', 'fanout_sum', '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联',
{ column: 'total_amount', sql_excerpt: 'SUM(o.total_amount)' })
{ column: 'total_amount', sql_excerpt: 'SUM(o.total_amount)', relation_id: 'r0a1b2c3d4e5' })
const WARN = sqlIssue('warning', 'missing_valid_filter', '「订单」定义了有效记录条件「status <> 9」,查询中没有按它筛选')
const INFO = sqlIssue('info', 'join_unconfirmed', '「订单」与「门店」按推断的关系关联(o.store_id = s.id),这条关系尚未确认', { table: 'stores' })
const ops = [
@@ -1840,6 +1870,8 @@ await section('SQL 检查(数据目录阶段 4B):指标所依据的查询
const asCheck = mod.issueSqlCheck(out.check.issues[0])
check('问题按 SQL 检查读得出级别、表、列、SQL 片段', asCheck?.level === 'error' && asCheck.table === 'orders' && asCheck.column === 'total_amount'
&& asCheck.sql_excerpt === 'SUM(o.total_amount)' && out.check.issues[0].field === 'args.sql', JSON.stringify(asCheck))
// 和证据面板、运行时间线同一个读法(lib/sqlcheck 的 sqlCheckOf):关系编号不丢
check('……关系编号也留着(以前另写的一份丢了 relation_id)', asCheck?.relation_id === 'r0a1b2c3d4e5', JSON.stringify(asCheck))
check('不是 SQL 检查的问题不按它读', mod.issueSqlCheck({ message: 'x', code: 'tools_dropped' }) === null)
const infoOnly = mod.decodeCopilot([{ op: 'final', graph: { nodes: [{ id: 'q' }] }, issues: [INFO] }], { context: 'canvas' })
.find((s) => s.code === 'sql_check')
@@ -1847,5 +1879,28 @@ await section('SQL 检查(数据目录阶段 4B):指标所依据的查询
`${infoOnly?.level} ${infoOnly?.next}`)
})
await section('并行查询配对(A1)、查询的 SQL 检查(tool.end.checks)、指标的问题指到来源查询的 SQL(B2)', async () => {
const all = flatten(mod.decodeRun(synthetic.parallelQueryRun(), { status: 'succeeded' }))
const qa = all.find((s) => s.kind === 'query' && s.nodeId === 'q_a')
const qb = all.find((s) => s.kind === 'query' && s.nodeId === 'q_b')
check('两个并行的查询节点用同一个工具:各自的结果配回各自的节点', !!qa?.result?.includes('119160') && !!qb?.result?.includes('"refunds"')
&& !qb.result.includes('119160'), `${qa?.result?.slice(0, 40)} | ${qb?.result?.slice(0, 40)}`)
check('……两行都收尾了,没有一直显示进行中', qa?.status === 'done' && qb?.status === 'done', `${qa?.status} ${qb?.status}`)
check('……检查结果挂在出问题的那条查询上', qa?.checks?.length === 1 && !qb?.checks, JSON.stringify(qb?.checks))
const [c] = qa?.checks ?? []
check('查询那一行带着 SQL 检查结果(级别、规则、表和列、关系编号),副标题数一下', c?.code === 'fanout_sum' && c.level === 'error'
&& c.table === 'orders' && c.column === 'total_amount' && c.relation_id === 'r1a2b3c4d5e6'
&& qa.sub === 'SQL 检查:1 处错误' && qa.level === 'warn', `${qa?.sub} ${qa?.level}`)
const rows = all.filter((s) => s.code === 'metric_sql_check')
check('同一条查询影响两个指标:时间线一行', rows.length === 1, String(rows.length))
const m = rows[0]
check('……标题点名两个指标,副标题是问题', m?.title === '指标「订单金额」「客单价」所依据的查询未通过 SQL 检查'
&& m.sub === synthetic.FANOUT_PROBLEM, `${m?.title} / ${m?.sub}`)
check('……「打开设置」指到来源查询节点的 SQL,不是口径卡', m?.fix === 'canvas' && m.fixNode === 'q_a' && m.fixField === 'args.sql'
&& m.fixLabel === '订单金额查询' && m.nodeId === 'card', `${m?.fixNode} ${m?.fixField} ${m?.fixLabel}`)
check('……检查结果也挂在这一行,展开能看', m?.checks?.length === 1 && m.checks[0].code === 'fanout_sum')
check('……下一步说改来源查询的 SQL', !!m?.next?.includes('来源查询的 SQL'), m?.next)
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)
+24 -5
View File
@@ -1039,6 +1039,7 @@ await section('incomplete', '截断的查询结果整组算出来的指标:标
await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤列出级别、规则名、说明、表和列、SQL 片段;指标未通过时标出来并写明原因', async () => {
await openQ(page, '1,288')
check('没查出问题的查询步骤不出 SQL 检查', await panel(page).locator('[data-ev-sql-checks]').count() === 0)
check('没有冻结目录的查询步骤不写数据目录版本', await panel(page).locator('[data-ev-catalog]').count() === 0)
await page.keyboard.press('Escape')
await openQ(page, '45,678.5元', { wait: '[data-ev-expression]' })
check('通过的指标不出「SQL 检查未通过」', await panel(page).locator('[data-ev-sql-check-failed], [data-ev-sql-check-reason]').count() === 0)
@@ -1051,10 +1052,15 @@ await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤
{ code: 'missing_valid_filter', level: 'warning', message: '「订单」定义了有效记录条件「status <> 9」,查询中没有按它筛选。', table: 'orders' },
]
const REASON = '所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算),结果不可靠'
// 查询步骤带着这次查询对照的数据目录版本(_catalog_of:表结构快照里冻结的那份)
const CATALOG = [{ table: 'orders', label: '订单', version: 3 }, { table: 'order_items', version: 1 }]
const bad = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, checks: CHECKS } : st.step === 'metric' ? { ...st, sql_check_failed: true, sql_check_reason: REASON } : st)) }))
? { ...st, checks: CHECKS, schema_artifact: 'c'.repeat(64), catalog: CATALOG }
: st.step === 'metric' ? { ...st, sql_check_failed: true, sql_check_reason: REASON } : st)) }))
await openQ(bad.page, '1,288', { wait: '[data-ev-sql-checks]' })
const p = bad.page.locator('[data-evidence-panel]')
const cat = await p.locator('[data-ev-catalog]').innerText().catch(() => '')
check('查询步骤写明对照的数据目录版本:有中文名写中文名,没有写表名', cat === '数据目录:订单 第 3 版、order_items 第 1 版', cat)
const items = p.locator('[data-ev-sql-checks] [data-sql-check]')
const levels = await items.evaluateAll((els) => els.map((e) => e.getAttribute('data-level')))
check('三条都列在 SQL 下面,错误在前、提示在后', levels.join(',') === 'error,warning,info', levels.join(','))
@@ -1067,7 +1073,7 @@ await section('sqlcheck', 'SQL 检查(数据目录阶段 4B):查询步骤
const want = [await tokenColor(bad.page, 'color', 'var(--st-failed)'), await tokenColor(bad.page, 'color', 'var(--st-waiting)'),
await tokenColor(bad.page, 'color', 'var(--accent)')]
check('级别的颜色走令牌:错误 --st-failed、提醒 --st-waiting、提示 --accent', tones.join('|') === want.join('|'), tones.join('|'))
check('悬停写明依据的确证程度', (await items.first().locator('[data-sql-level]').getAttribute('title')) === '依据已确认,结果必然有误')
check('悬停写明依据的确证程度', (await items.first().locator('[data-sql-level]').getAttribute('title')) === '依据已核实,结果很可能有误,需要修改')
check('不露规则编号', !/fanout_sum|missing_valid_filter|join_unconfirmed/.test(await p.innerText()))
await bad.page.keyboard.press('Escape')
await openQ(bad.page, '45,678.5元', { wait: '[data-ev-sql-check-failed]' })
@@ -3046,14 +3052,18 @@ if (SHOTS) {
// 推断来源的答复带 merge(经过的每一次合并)。答复按 backend/app/api/evidence.py 的 _merge_view、_query_steps 的形状改
// ---------------------------------------------------------------------------
const MERGE_SQL = 'SELECT o.order_id, o.region, o.amount, t.target FROM o JOIN t ON o.region = t.region ORDER BY o.order_id'
function asMerge(body, traced, { withInput = true } = {}) {
// 合并输入自己的 SQL 检查(_merge_view 的 inputs[].checks,形状同查询步骤的 checks)
const INPUT_CHECKS = [{ code: 'fanout_sum', level: 'error', table: 'orders', column: 'amount',
message: '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。请先按订单汇总明细再关联。' }]
function asMerge(body, traced, { withInput = true, inputChecks = false } = {}) {
const q = body.chain.find((st) => st.step === 'query')
const merged = {
...q, tool: undefined, source: '合并查询', sql: MERGE_SQL,
merge: {
sql: MERGE_SQL,
inputs: [
{ alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true },
{ alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true,
...(inputChecks ? { checks: INPUT_CHECKS } : {}) },
{ alias: 't', node_id: 'targets', label: '区域目标', query: null, rows: 4, source: 'plan', artifact: 'b'.repeat(64), sealed: true },
],
warnings: [{ code: 'rows_grew', message: '合并结果有 14 行,多于行数最多的输入「o」(12 行):合并键可能不唯一,同一行被重复匹配' }],
@@ -3097,15 +3107,24 @@ await section('merge', '合并查询:表级来历(输入、合并 SQL、警
check('输入步骤标明是哪次合并的输入,高亮追到的那一格', into.includes('合并查询 Q3 的输入')
&& await parts.nth(1).locator('td[data-highlight="cell"]').count() === 1, into)
check('合并查询不是数据源:不说它「已不存在」', await p.locator('[data-ev-query-mask-note]').count() === 0)
check('输入没查出问题:合并步骤里不出输入的 SQL 检查', await p.locator('[data-ev-merge-input-checks]').count() === 0)
await traced.ctx.close()
const flat = await probeQ((id, body) => (id === qseg('1,288') ? asMerge(body, [{
cell: [5, 'amount'], input: null, query: null, row: null, column: null, note: '没有逐格来历',
}], { withInput: false }) : body))
}], { withInput: false, inputChecks: true }) : body))
await openQ(flat.page, '1,288')
const none = await flat.page.locator('[data-evidence-panel] [data-ev-merge-trace="none"]').innerText().catch(() => '')
check('追不到的格:写明没有逐格来历、只有表级来历,不接输入步骤', none.includes('没有逐格来历')
&& none.includes('表级来历') && await flat.page.locator('[data-evidence-panel] [data-ev-query]').count() === 1, none)
// 没有输入步骤时,输入查询的 SQL 检查结果要在合并步骤里看得到(指标正是因为它标了「存疑」)
const inChecks = flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="o"]')
const inText = await inChecks.innerText().catch(() => '')
check('追不到逐格来历时,合并步骤的输入下面列出它自己的 SQL 检查(级别、规则名、说明)', inText.includes('输入 o 的 SQL 检查')
&& inText.includes('错误') && inText.includes('一对多关联后重复计算') && inText.includes('会重复计算')
&& await inChecks.locator('[data-sql-check="fanout_sum"][data-level="error"]').count() === 1, inText.replace(/\s+/g, ' '))
check('……没查出问题的输入不出', await flat.page.locator('[data-evidence-panel] [data-ev-merge-input-checks="t"]').count() === 0)
check('……不露规则编号', !/fanout_sum/.test(await flat.page.locator('[data-evidence-panel] [data-ev-merge]').innerText()))
await flat.ctx.close()
const wideId = pseg('wide')
+31
View File
@@ -1981,6 +1981,37 @@ await section('证据页签:左边报告、右边常驻面板、下方审计
await page.keyboard.press('Escape')
})
await section('证据页签:降档时报告上方写明档位和原因,SQL 检查没通过的单独说,「都有出处」不画成绿色(B5)', async () => {
// 成果的 _issuance 带 sql_checks(engine/nodes/io.py):数字都有出处,降档却是因为算数的查询没通过 SQL 检查
const EV_SQL = 'fake0evsqldegrade000000000000'
const PROBLEM = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'
const GAP = `查询「取订单」(Q1)未通过 SQL 检查(${PROBLEM}),结果不可靠;受影响的引用:Q1 第 1 行「gmv」`
const issuance = { tier: 'degraded', calibers: [], metrics_checked: 0, missing_required: [], missing_expected: [],
unmatched_numbers: [], matched_numbers: 10, matched: [], gaps: [GAP],
sql_checks: [{ query: '查询「取订单」(Q1)', node_id: 'fetch', problems: [PROBLEM], refs: ['Q1 第 1 行「gmv」'], gap: GAP }] }
evFakes(EV_SQL, { output: { ...fxe.output, _issuance: issuance }, graph: fxe.graph, audit: fxe.audit, csv: fxe.audit_csv })
await page.route(new RegExp(`/api/runs/${EV_SQL}/evidence/segments/`), (r) => r.fulfill({ status: 404, json: { detail: '没有这个片段' } }))
try {
await openEvidence(EV_SQL)
await page.locator('[data-evidence-report=write] [data-evidence-doc]').waitFor({ timeout: 5000 }).catch(() => {})
const banner = page.locator('[data-view-pane=evidence] [data-evidence-issuance] [data-issuance-banner=degraded]')
check('报告上方写明出具档位', await banner.count() === 1 && (await banner.innerText().catch(() => '')).includes('降档出具'))
const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……SQL 检查导致的降档在显眼处说明:哪次查询、什么问题、受影响的引用', sql.includes('降档原因:所依据的查询未通过 SQL 检查')
&& sql.includes('查询「取订单」(Q1)') && sql.includes(PROBLEM) && sql.includes('Q1 第 1 行「gmv」'), sql)
check('……那一句不在其余缺口里重复', await banner.locator('[data-issuance-gaps]').count() === 0)
const tally = page.locator('[data-evidence-report=write] [data-evidence-tally-numbers]')
const [color, done] = await Promise.all([tally.evaluate((el) => getComputedStyle(el).color).catch(() => ''),
page.evaluate(() => { const s = document.createElement('span'); s.style.color = 'var(--st-done)'; document.body.append(s)
const c = getComputedStyle(s).color; s.remove(); return c })])
check('降档时报告上的数字计数不画成绿色', !!color && color !== done, `${color} vs ${done}`)
} finally {
for (const k of [...fakes.keys()]) if (k.includes(EV_SQL)) fakes.delete(k)
}
await openEvidence(EV)
check('没有出具契约的运行:报告上方不出出具横幅', await page.locator('[data-view-pane=evidence] [data-evidence-issuance]').count() === 0)
})
await section('证据页签:同一段文字里好几条同样的违规、正文里点不开的行', async () => {
// 夹具同样是后端真跑的:粗体、链接里反引号写的可疑名字只记违规、不切片段——同一段文字上两条 unknown_entity、
// 两条 unverified_entity(片段和 code 都相同),列表序号 100. 是结构片段上的裸数字。这几行正文里都点不开
+41
View File
@@ -709,6 +709,10 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
check('合并 SQL 有标签、带复制', (await row.innerText()).includes('合并 SQL')
&& await row.getByRole('button', { name: '复制合并 SQL' }).count() === 1)
check('结果预览画成表格(列是合并结果的列)', (await row.locator('table th').allInnerTexts()).some((t) => t.includes('到店人数')))
// 合并结果 8 行、预览只存了 5 行:说「此处仅为预览」和「显示前 5 / 8 行」,不说「查询已达行数上限」(A3)
const foot = (await row.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('预览只存了前几行:写「显示前 5 / 8 行」和「仅为预览」,不说查询已达行数上限', foot.includes('显示前 5 / 8 行')
&& foot.includes('此处仅为预览') && !foot.includes('查询已达行数上限'), foot.slice(-120))
check('输入写在合并 SQL 前面:先看合并的是什么,再看怎么合并', await row.evaluate((el) => {
const m = el.querySelector('[data-step-merge]')
const pre = el.querySelector('pre')
@@ -731,6 +735,32 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
await narrow.page.close()
})
await section('并行查询各配各的;查询的 SQL 检查;指标的问题指到来源查询的 SQL(A1、B2)', async () => {
const { page, errors } = await open('syn=parallel&link=1', { w: 1100, h: 900, name: 'parallel' })
const qa = page.locator('[data-node-id="q_a"]').filter({ hasText: 'SQL 检查:1 处错误' }).last()
check('出问题的那条查询:行上就说「SQL 检查:1 处错误」', await qa.count() === 1)
await qa.locator('button').first().click()
await page.waitForTimeout(200)
const list = qa.locator('[data-step-sql-checks] [data-sql-check="fanout_sum"][data-level="error"]')
const text = (await qa.locator('[data-step-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('展开:用 SQL 检查清单列出级别、规则名、说明、涉及的表和列', await list.count() === 1 && text.includes('错误')
&& text.includes('一对多关联后重复计算') && text.includes('orders.total_amount') && !text.includes('fanout_sum'), text)
const qb = (await page.locator('[data-node-id="q_b"]').last().innerText().catch(() => '')).replace(/\s+/g, ' ')
check('另一条查询没有检查结果,也没被配上别人的结果', !qb.includes('SQL 检查') && !qb.includes('119'), qb)
const metric = page.locator('[data-step-code="metric_sql_check"]')
check('两个指标出自同一条查询:时间线一行', await metric.count() === 1, String(await metric.count()))
const fix = metric.locator('[data-fix="canvas"]')
check('「打开设置」落到来源查询节点的 SQL:写「打开「订单金额查询」的 SQL」', (await fix.innerText().catch(() => '')).trim()
=== '打开「订单金额查询」的 SQL' && await fix.getAttribute('data-fix-node') === 'q_a'
&& await fix.getAttribute('data-fix-field') === 'args.sql', await fix.innerText().catch(() => ''))
await fix.click()
const opened = await page.evaluate(() => window.__opened ?? [])
check('……点了打开的是来源查询节点的 args.sql,不是口径卡', JSON.stringify(opened) === JSON.stringify([['q_a', 'args.sql']]),
JSON.stringify(opened))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.close()
})
await section('参考了哪些表:画布右栏和问数据页(context 操作)', async () => {
const c = await open('syn=copilot-context&dense=1', { w: 380, h: 800, name: 'copilot-context' })
const row = c.page.locator('[data-step-code="copilot_context"]')
@@ -1273,6 +1303,17 @@ await section('放弃、结构化的报错、恢复的轮次、出具横幅(RE
check('恢复的轮次:头部照样写几次查询', head.includes('3 次查询'), head.replace(/\n/g, ' '))
await r.page.close()
// 降档是因为查询没通过 SQL 检查(B5):横幅单列一块写是哪次查询、什么问题、影响了哪些引用;那一句不在其余缺口里重复
const q = await open('syn=issued-sql', { w: 1100, h: 900, name: 'issued-sql' })
const banner = q.page.locator('[data-issuance-banner=degraded]')
const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('SQL 检查导致的降档单列一块:写降档原因、哪次查询、问题、受影响的引用、下一步', sql.includes('降档原因:所依据的查询未通过 SQL 检查')
&& sql.includes('查询「订单金额查询」(Q1)') && sql.includes('求和会重复计算') && sql.includes('受影响:指标「订单金额」、Q1 第 1 行「gmv」')
&& sql.includes('修改来源查询的 SQL'), sql)
const rest = (await banner.locator('[data-issuance-gaps]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……那一句不在「校验未全部完成」里重复,其余缺口照列', !rest.includes('未通过 SQL 检查') && rest.includes('协作团队'), rest)
await q.page.close()
const i = await open('syn=issued', { w: 1100, h: 900 })
check('出具横幅带 data-issuance-banner(画布印章据此滚过来)', await i.page.locator('[data-issuance-banner]').count() === 1)
const live = await i.page.locator('[data-turn] [role="status"][aria-live="polite"]').first().innerText()