合入最终验收(免模型部分)查出的 12 项修正:取值观察的「量跌」「缺天」不再拿今天这个不完整的日子比、按日行数只数到观察当天、「今天」统一按服务端所在时区;同一列条件被改写时差异说明说清两边的写法(判等价不变);用量计量记对模型名;探查范围外的表照实说「在探查范围外」;对被驳回的运行 resume 说「被驳回」;固化口径草稿用传入的名字、报告指令不写死日期、指标名取中文名、frozen_into 去重;剖析对话框可以直接发起取值观察,定时维护挑不出表时触发记录写明原因;评测结果给实际问的问题、没跑的题单独计 not_run、stop_reason 照实写;按需加入范围的留痕记上运行发起人;召回消息按种类写答案条数;撤销分享返回页面标题;E4 只算有口径卡的版本。验收:后端全量 14312 通过;tsc、文案检查;相关的 13 项浏览器检查 4423 项全过;四种库的方言容器测试全过;泄露扫描干净

This commit is contained in:
ThearchyHelios
2026-10-17 09:31:58 +08:00
57 changed files with 1231 additions and 178 deletions
+13 -5
View File
@@ -114,8 +114,9 @@ GUIDANCE_NOT_TEXT = "起草要求要是一段文字(要清除起草要求请
class ProfileJobIn(BaseModel):
#: 要剖析的表(范围 selected,按给的顺序去重);最多 500 张(超了 422,说明是中文)
tables: list[str] | None = None
#: 不指定表时的范围:needed 需要剖析的表(缺省)、all 全部表(都不含视图、没有可剖析的项的表)
scope: Literal["needed", "all"] | None = None
#: 不指定表时的范围:needed 需要剖析的表(缺省)、all 全部表(都不含视图、没有可剖析的项的表)、observe 取值观察(只观察
#: 有数据目录、观察到期的表,不剖析;见 data/catalog_profile_observe)。maintenance 是定时维护用的,这里不收
scope: Literal["needed", "all", "observe"] | None = None
async def _resolved(session: AsyncSession, source_id: str) -> tuple[DataSource, Any]:
@@ -226,14 +227,21 @@ async def _detail(session: AsyncSession, source: Any, table: str, entry: catalog
async def _table_key(session: AsyncSession, source: Any, table: str) -> tuple[str, catalog.CatalogEntry | None]:
"""路径里的表名 → 目录的键。表结构里有的按表结构的写法;表结构里没有、但还留着目录的也认(便于清理)。"""
"""路径里的表名 → 目录的键。表结构里有的按表结构的写法;表结构里没有、但还留着目录的也认(便于清理)。都没有时 404,
说清是哪一种「不在」(_missing)。"""
key = catalog.resolve_table_name(source.schema_cache, table)
entry = await catalog.read_entry(session, source.id, key or table)
if key is None and entry is None:
raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构")
raise await _missing(session, source, table)
return key or table, entry
async def _missing(session: AsyncSession, source: Any, table: str) -> HTTPException:
"""表结构里没有这张表的 404:表已不在、数据源里没有、在探查范围外、读不到结构、说不清,和 /schema、/catalog/search、
目录清单的 absent_reason 同一套(source_objects.absent_message)。"""
return HTTPException(404, await source_objects.absent_message(session, source, table))
def _conflict(e: catalog.CatalogConflict) -> HTTPException:
return HTTPException(409, str(e))
@@ -906,7 +914,7 @@ async def catalog_table_assistant(source_id: str, table: str, payload: Assistant
row, source = await _resolved(session, source_id)
key = catalog.resolve_table_name(source.schema_cache, table)
if key is None:
raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构")
raise await _missing(session, source, table)
messages = [m.model_dump() for m in payload.messages]
if problem := catalog_assistant.conversation_problem(messages):
raise HTTPException(422, problem)
+39 -4
View File
@@ -1019,21 +1019,56 @@ async def _actions(session: AsyncSession, row: EvalRun) -> dict[str, Any]:
@router.get("/evaluations/{evaluation_id}/results")
async def evaluation_results(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""逐题结果:结论、各期望的结论(期望值和答案里对上的数、有没有引用、维度)、作答路径、复核、出具档位、SQL 差在哪、look、
耗时、按角色的用量,点 run_id 去运行详情。明细清理了的照实写 cleaned。"""
耗时、按角色的用量,点 run_id 去运行详情。明细清理了的照实写 cleaned。question 是这一题实际问的问题(变量渲染好了,
见 _asked_questions;说不准时为 null),question_template 是题目的原文(带 {{ schedule.… }} 变量的模板)。"""
from app.eval import runner
row = await _evaluation(session, evaluation_id)
results = [runner.result_dict(r) for r in (await session.execute(
select(runner.EvalResult).where(runner.EvalResult.eval_run_id == row.id)
.order_by(runner.EvalResult.seq))).scalars()]
questions = dict((await session.execute(select(EvalCase.id, EvalCase.question).where(
EvalCase.id.in_({r["case_id"] for r in results if r["case_id"]})))).all()) if results else {}
cases = {c.id: (c.question, c.case_hash) for c in (await session.execute(select(EvalCase).where(
EvalCase.id.in_({r["case_id"] for r in results if r["case_id"]})))).scalars()} if results else {}
asked = await _asked_questions(session, row, results, cases)
for r in results:
r["question"] = questions.get(r["case_id"])
template = (cases.get(r["case_id"]) or (None, None))[0]
r["question"], r["question_template"] = asked.get(r["id"]), template
r["cost"] = _result_cost(r.get("usage") or {})
return {"evaluation_id": row.id, "cleaned": row.cleaned_at is not None, "results": results}
async def _asked_questions(session: AsyncSession, row: EvalRun, results: list[dict[str, Any]],
cases: dict[str, tuple[str, str]]) -> dict[str, str | None]:
"""每一题实际问的问题 {结果 id: 问题}:题目的模板按这次评测的截至时间(as_of)、题集的时区、定时的 cron 渲染——和执行时
runner._unit 交给作答的是同一个算法、同一组输入,所以一字不差(as_of 在建评测时就定下、重试也不变)。题目在这次评测之后
改过(结果记的 case_hash 对不上现在的):现在的模板不一定是当时问的,改取作答运行的输入里的 question;也没有(反问、
建图失败没有运行,工作流题的输入里没有问题)就是 None,不拿现在的模板编一个。"""
from app.db.models import Run
from app.eval import runner
from app.eval.cases import render, variable_values
if not results:
return {}
leader = row if runner.leader_of(row) == row.id else await session.get(EvalRun, runner.leader_of(row))
suite = await session.get(EvalSuite, row.suite_id)
as_of = (leader.as_of if leader is not None else None) or row.as_of
values = variable_values(as_of, suite.timezone, ((leader or row).fingerprint or {}).get("cron")) \
if as_of is not None and suite is not None else None
stale = {r["id"] for r in results
if r["case_id"] not in cases or cases[r["case_id"]][1] != r["case_hash"] or values is None}
runs = {r["run_id"] for r in results if r["id"] in stale and r["run_id"]}
inputs = dict((await session.execute(select(Run.id, Run.input).where(Run.id.in_(runs))))
.all()) if runs else {}
out: dict[str, str | None] = {}
for r in results:
if r["id"] in stale:
given = (inputs.get(r["run_id"]) or {}).get("question") if r["run_id"] else None
out[r["id"]] = given if isinstance(given, str) and given else None
else:
out[r["id"]] = render(cases[r["case_id"]][0] or "", values or {})
return out
def _result_cost(usage: dict[str, Any]) -> dict[str, Any]:
"""一题的花费:助手(runner 记的 assistant 已含复核)+ 写作 + 结论裁判,和这次评测记在账上的同一份;估不出金额的调用另计
次数(界面写「≥」或「金额无法估算」)。加在服务端:界面不算数。"""
+5
View File
@@ -418,6 +418,8 @@ async def resume_run(
approval = await session.get(Approval, target)
if approval is not None and approval.run_id == run_id and approval.status == "pending":
return await _respond_to(target, payload.response, x_actor)
from app.engine.runner import ContinueRefused
try:
# approval_id 以前定义了却从没传下去——一次运行里有多条待审批时,
# 引擎无从知道这次回复的是哪一条
@@ -428,6 +430,9 @@ async def resume_run(
)
except KeyError as e:
raise _missing(e) from e
except ContinueRefused as e:
# 被驳回的(run_rejected):和 /continue 同一个机器码
raise CodedHTTPException(409, str(e), e.code) from e
except ValueError as e:
raise HTTPException(409, str(e)) from e
+5 -2
View File
@@ -216,7 +216,9 @@ async def list_shares(app_id: str, session: AsyncSession = Depends(get_session),
@_manage.delete("/{app_id}/shares/{share_id}")
async def revoke_share(app_id: str, share_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""撤销:之后打开回 410「链接已撤销」。撤销过的再撤销照旧交回它(不重复记审计)。管理员关了开关也能撤销。"""
"""撤销:之后打开回 410「链接已撤销」。撤销过的再撤销照旧交回它(不重复记审计)。管理员关了开关也能撤销。交回的形状和
建、列的同一个(page_title 是页面标题,取法同列表:应用当前的版本)。"""
from app.api.apps import _previous
from app.apps.audit import record
app, _ = await _managed(session, app_id, x_actor)
@@ -228,7 +230,8 @@ async def revoke_share(app_id: str, share_id: str, session: AsyncSession = Depen
await record(session, action="app.share_revoke", subject_kind="view_share", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "page_id": row.page_id, "prefix": row.prefix})
await session.commit()
return share_out(row)
version = await _previous(session, app)
return share_out(row, _titles(version.spec if version is not None else None))
# --------------------------------------------------------------------------
+19 -6
View File
@@ -22,8 +22,9 @@
说明源上正有别的事,接口回 409;占到了,执行期间新建的任务排在它后面。**队里的事是开放的**:运行时不认具体哪几种,
每种在自己的模块里 register_lane_kind 登记一次(kind → 叫法),进队时只核对登记过——第 2 波的后台探查(introspect)
登记一行就排进同一条队,不用改这里;拼错的 kind 当场报错,不会悄悄排成一种谁都不认识的事。
- **close_job / interrupt_on_startup**:收尾(还没结束的表记成某个状态,任务记成某个状态),和服务启动时的整理:排队、
进行中的标 interrupted,没结束的表记 cancelled。**不自动续跑**:复制出来的数据目录(检查脚本、验收副本)启动时不能
- **close_job / interrupt_on_startup**:收尾(还没结束的表记成某个状态,任务记成某个状态;可选的 finish 在同一个事务里
补一步,评测拿它按改好的逐项状态重算计数、记停下的原因),和服务启动时的整理:排队、进行中的标 interrupted,没结束的
表记 cancelled。**不自动续跑**:复制出来的数据目录(检查脚本、验收副本)启动时不能
自己去调模型、查业务库,界面让人点「重试」接着做。
进程内登记、进程内的队:服务端是单进程部署,多进程部署时要换成库里的锁。
@@ -243,9 +244,16 @@ lanes = SourceLanes()
# ==========================================================================
#: 收尾时在同一个事务里补写的那一步(close_job、cancel_queued 的 finish):表和任务的状态已经改好、还没提交
Finish = Callable[[AsyncSession], Awaitable[None]]
async def close_job(session: AsyncSession, job_model: Any, task_model: Any, job_id: str, job_status: str,
job_error: str | None, task_status: str, task_error: str | None) -> None:
"""收尾:还没结束的表记 task_status(原因 task_error),任务(还没结束的话)记 job_status。提交。"""
job_error: str | None, task_status: str, task_error: str | None, *,
finish: Finish | None = None) -> None:
"""收尾:还没结束的表记 task_status(原因 task_error),任务(还没结束的话)记 job_status。提交。
finish:提交前在同一个事务里再做的一步(评测按改好的逐项状态重算计数、记停下的原因)。起草、剖析、探查不传,照旧。"""
now = utcnow()
await session.execute(
update(task_model).where(task_model.job_id == job_id, task_model.status.in_(OPEN))
@@ -253,12 +261,15 @@ async def close_job(session: AsyncSession, job_model: Any, task_model: Any, job_
await session.execute(
update(job_model).where(job_model.id == job_id, job_model.status.in_(ACTIVE))
.values(status=job_status, error=job_error, finished_at=now).execution_options(synchronize_session=False))
if finish is not None:
await finish(session)
await session.commit()
async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any, job_id: str) -> bool:
async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any, job_id: str, *,
finish: Finish | None = None) -> bool:
"""停止一个排队中的任务:任务和没结束的表直接记 cancelled。已经被后台认领(开始了)返回 False,交给调用方按进行中
的停。"""
的停。finish 同 close_job(只在真停了时做)。"""
now = utcnow()
claimed = await session.execute(
update(job_model).where(job_model.id == job_id, job_model.status == "queued")
@@ -269,6 +280,8 @@ async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any,
await session.execute(
update(task_model).where(task_model.job_id == job_id, task_model.status.in_(OPEN))
.values(status="cancelled", finished_at=now).execution_options(synchronize_session=False))
if finish is not None:
await finish(session)
await session.commit()
return True
+14 -8
View File
@@ -45,7 +45,7 @@ import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from datetime import date as _date, datetime, timezone
from datetime import date as _date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, NamedTuple
@@ -493,17 +493,23 @@ class SqlDialect:
hi = self.date_literal(VALID_DATE_BEFORE, text=text)
return f"SELECT MAX({column}) FROM {table} WHERE {column} >= {lo} AND {column} < {hi}"
def _window(self, column: str, since: Any, text: bool) -> str:
"""时间窗口的条件:since 起(含),并且早于占位日期 9999-12-31——PostgreSQL 的 infinity、Oracle 的 9999-12-31
都大于任何一天,不截掉上端就会在「最近 35 天」里多出一天。"""
def _window(self, column: str, since: Any, text: bool, before: Any = None) -> str:
"""时间窗口的条件:since 起(含),并且早于 before(不含;缺省是占位日期 9999-12-31——PostgreSQL 的 infinity、
Oracle 的 9999-12-31 都大于任何一天,不截掉上端就会在「最近 35 天」里多出一天)。"""
return (f"{column} >= {self.date_literal(since, text=text)} "
f"AND {column} < {self.date_literal(VALID_DATE_BEFORE, text=text)}")
f"AND {column} < {self.date_literal(before or VALID_DATE_BEFORE, text=text)}")
def day_volume_sql(self, table: str, column: str, since: Any, *, text: bool = False) -> str:
"""最近若干天按天计数:since 起(含)每天几行。只在日期列打头有索引时对大表发(走索引的范围扫描)。"""
def day_volume_sql(self, table: str, column: str, since: Any, until: Any, *, text: bool = False) -> str:
"""最近若干天按天计数:since 到 until(都含;until 是观察当天)每天几行。只在日期列打头有索引时对大表发(走索引的
范围扫描)。上界是 until 的第二天(不含):业务日期有未来值(预约、计划类的日期,或者写错的)时,今天以后的日子
不进窗口——它们不是「最近的整天」,也不该被当成缺天(data/value_drift)。时间列上「< 第二天」把 until 当天的
时分秒都算进来;文字存的日期按字符串比,同样成立。"""
last = until if isinstance(until, _date) else _date.fromisoformat(str(until))
if isinstance(last, datetime):
last = last.date()
day = self.day_trunc(column)
return (f"SELECT {day} AS d, COUNT(*) AS n FROM {table} "
f"WHERE {self._window(column, since, text)} GROUP BY {day} ORDER BY d")
f"WHERE {self._window(column, since, text, last + timedelta(days=1))} GROUP BY {day} ORDER BY d")
def window_value_counts_sql(self, table: str, column: str, date_column: str, since: Any, n: int, *,
text: bool = False) -> str:
+15 -2
View File
@@ -692,6 +692,18 @@ async def _behind_draft(session: AsyncSession, source_id: str) -> bool:
return catalog_jobs.sync_drafting(source_id) or await catalog_jobs.active_job(session, source_id) is not None
async def nothing_reason(session: AsyncSession, row: Any, source: Any, scope: str) -> str | None:
"""按此刻的表结构和目录,这个范围一张表都挑不出来时为什么(ProfileNothing 的原话);挑得出、或者排在起草后面(开始时按
起草后的目录再挑,现在说不准)返回 None。定时维护建出 0 张表的任务时用它把原因记进触发记录。"""
if await _behind_draft(session, row.id):
return None
try:
await _pick(session, row, source, tables=None, scope=scope)
except ProfileNothing as e:
return str(e)
return None
async def create_job(session: AsyncSession, row: Any, source: Any, *, tables: list[str] | None, scope: str,
settings: ProfileSettings, actor: str | None, retry_of: str | None = None,
trigger: str = "manual", fire_at: str | None = None, schedule_id: str | None = None,
@@ -1161,5 +1173,6 @@ __all__ = ["INTERRUPTED_ERROR", "KEEP_JOBS", "MAX_TABLES", "NOTHING_AT_ALL", "NO
"NOTHING_TO_OBSERVE", "NothingToRetry", "OBSERVING_SCOPES", "SCOPES", "task_modes", "trigger_of",
"ProfileBusy", "ProfileFinished", "ProfileJobs", "ProfileNothing", "THEN_PROFILE", "active_job",
"busy_message", "cancel", "create_job", "due_reason", "ensure_free", "job_brief", "job_dict", "job_out",
"last_detail", "last_facts", "last_profiles", "outside_of", "latest_job", "load", "load_task", "manager", "prune", "public_last",
"retry_tables", "startup", "task_detail", "task_row", "task_rows", "unsaved_error", "waiting_for"]
"last_detail", "last_facts", "last_profiles", "outside_of", "latest_job", "load", "load_task", "manager",
"nothing_reason", "prune", "public_last", "retry_tables", "startup", "task_detail", "task_row", "task_rows",
"unsaved_error", "waiting_for"]
+14 -8
View File
@@ -8,7 +8,11 @@
- rows:这张表的行数(剖析开头估行数那一条的结果,不另发查询);口径 stats(数据库统计的估算值)/ count(数到上限);
- codes_observe:码值列的取值分布——**所有**码值列都观察,包括人工确认过、标了已列全的(目录-D6);只读,不改含义;
- freshness:业务日期列(已确认或已验证)的最新日期;
- daily_volume:业务日期最近 35 天每天的行数。
- daily_volume:业务日期最近 35 天每天的行数,数到观察当天为止(今天以后的日子不进窗口,见 SqlDialect.day_volume_sql)。
**「今天」按服务端所在时区**(local_today,和待办汇总的 notify.digest_at 同一个口径):窗口从哪天起、数到哪天、比较器的
「今天」(最近一个整天是哪天、断档离今天几天)都用它。业务日期是本地日期,按 UTC 的日期算,东八区凌晨 0–8 点的「今天」
还是前一天,这几样都会差一天。服务端和业务库不在一个时区时,差的那几个小时照实差着(业务库的时区探查不到)。
**大表的口径**(目录-D7):小表(行数不超过整表统计的上限)看整表(full)。大表:业务日期已确认或已验证、而且这一列打头
有索引(探查记下的 index_first_columns)时看最近 35 天的窗口(window,走索引的范围扫描);PostgreSQL / Oracle 没有就块采样
@@ -139,12 +143,13 @@ def sample_percent(rows: int | None, target: int) -> float | None:
return next((p for p in SAMPLE_LADDER if p <= want), SAMPLE_LADDER[-1])
def _today() -> date:
return datetime.now(timezone.utc).date()
def local_today(now: datetime | None = None) -> date:
"""观察用的「今天」:服务端所在时区的日期(和待办汇总的 notify.digest_at 同一个口径,见模块说明)。now 缺省是现在。"""
return (now or utcnow()).astimezone().date()
def _since() -> date:
return _today() - timedelta(days=WINDOW_DAYS)
return local_today() - timedelta(days=WINDOW_DAYS)
def _window_ok(cx: Any, meta: Mapping[str, Any], day: str | None) -> bool:
@@ -271,8 +276,9 @@ async def _dates(cx: Any, table: str, kind: str, column: str, meta: Mapping[str,
result.observations.append(_observation("freshness", column, {"method": "full"},
{"max": str(value)[:19]}, []))
return
since = _since()
res = await cx.run(d.day_volume_sql(t, c, since, text=text), max_rows=WINDOW_DAYS + 2)
today = local_today()
since = today - timedelta(days=WINDOW_DAYS)
res = await cx.run(d.day_volume_sql(t, c, since, today, text=text), max_rows=WINDOW_DAYS + 2)
days: dict[str, int] = {}
for row in res.rows:
day = str(row[0])[:10] if row and row[0] is not None else ""
@@ -280,7 +286,7 @@ async def _dates(cx: Any, table: str, kind: str, column: str, meta: Mapping[str,
days[day] = days.get(day, 0) + (cp._as_int(row[1]) or 0)
basis = {"method": "window", "column": column, "days": WINDOW_DAYS, "since": since.isoformat()}
result.observations.append(_observation("daily_volume", column, basis,
{"since": since.isoformat(), "until": _today().isoformat(),
{"since": since.isoformat(), "until": today.isoformat(),
"days": days}, []))
@@ -396,7 +402,7 @@ async def _save_one(session: AsyncSession, source: Any, table: str, obs: Mapping
current = vd.Obs(kind, obs["basis_key"], obs["data"], now)
past = [vd.Obs(h.kind, h.basis_key, h.data or {}, h.at) for h in history]
fresh = 0
for f in vd.compare(current, past, today=now.date(), known=known, complete=catalog.codes_complete(item)):
for f in vd.compare(current, past, today=local_today(now), known=known, complete=catalog.codes_complete(item)):
after = dict(f.after) if isinstance(f.after, Mapping) else {"value": f.after}
if f.revoke_complete:
version = await _revoke_complete(session, source.id, table, column)
+2 -2
View File
@@ -41,7 +41,7 @@ from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import catalog
from app.data import catalog, source_objects
from app.db.models import Run
#: 队列的筛选:全部、影响数字的项、名称和说明
@@ -346,7 +346,7 @@ async def review_batch(session: AsyncSession, source: Any, items: Iterable[Batch
key = known[item.table_name]
if key is None:
results[i] = _result(item, ok=False, code=CODE_TABLE_MISSING,
error=f"数据源中没有表 {item.table_name},可能已被删除或尚未探查结构")
error=await source_objects.absent_message(session, source, item.table_name))
continue
groups.setdefault(key, []).append(i)
at = catalog.now_iso()
+9 -1
View File
@@ -13,7 +13,10 @@ observe 取值观察,可多选,没设过是三步都做。
**到点**(fire,持着 scheduler 的 launch_lock):按顺序建探查任务 → 剖析任务(范围 needed / observe / maintenance,按选的
步骤),任务上记 trigger=schedule 和 fire_at,靠 SourceLanes 串行:剖析排在同一次触发的探查后面(等探查进了同源队才建剖析
任务),开始时按那时的表结构和目录挑表(挑出 0 张也照建,任务以 0 张表结束、提醒里写明为什么)。预算、止损照剖析设置。
任务),开始时按那时的表结构和目录挑表(挑出 0 张也照建,任务以 0 张表结束、提醒里写明为什么)。建任务时就判断得出会是 0 张
的(这次没有探查、也没排在起草后面,开始时表结构和目录都不会变——比如有数据目录的表都在 12 小时内观察过),原因另记进触发
记录的 inputs.skipped 和 reason;同一次触发里有探查、或者排在起草后面时说不准(探查完、起草完可能就有了),保持现状,只在
任务的提醒里写。预算、止损照剖析设置。
照实记没执行的:数据源不存在、已停用、是上传表格或记录表时整次不执行(failed_to_start);剖析没开时剖析、观察两步不执行、
探查照做;上一次的维护(或别的探查、剖析)还没做完时这次跳过(skipped_overlap)。每次触发记下建了哪几个任务、哪几步没执行、
为什么(触发记录的 inputs:{steps, jobs, skipped})。
@@ -221,6 +224,11 @@ async def _start(source_id: str, steps: list[str], *, actor: str, fire_at: str,
table_versions.SnapshotMissing) as e:
skipped += [{"step": s, "reason": str(e)} for s in wanted]
return jobs, skipped
if not tasks and introspect_id is None and (why := await catalog_profile_jobs.nothing_reason(
session, row, view, scope)):
# 建任务时就挑不出表、这次又没有探查(开始时表结构不会变)、也没排在起草后面(目录不会变):任务会以 0 张表
# 结束,原因照实记进触发记录,不只在剖析任务的提醒里。任务照建(触发照旧是 started)
skipped += [{"step": s, "reason": why} for s in wanted]
catalog_profile_jobs.manager.start(job.id)
jobs.append({"step": "+".join(wanted), "kind": "profile", "id": job.id, "scope": scope, "tables": len(tasks)})
return jobs, skipped
+29 -1
View File
@@ -825,6 +825,33 @@ async def absent_reasons(session: AsyncSession, row: Any, names: Iterable[str])
return out
#: 表结构里没有的表给人看的一句(接口的 404、批量审阅逐条的错误):和界面的 CATALOG_ABSENT_TEXT、/schema 的说法
#: (schema_view.absent_detail,给模型看)同一套「不在」。{table} 是点名的表
ABSENT_MESSAGE = {
ABSENT_REMOVED: "数据源中已经没有表 {table}(已删除或改名)",
ABSENT_MISSING: "数据源中没有表 {table}(全库的对象清单里也没有)",
ABSENT_OUT_OF_SCOPE: ("表 {table} 在探查范围外:对象索引里有它,但没有读取结构。在数据源卡片的「探查范围」里加入它,"
"或在数据目录的搜索框搜索它并「加入范围」"),
ABSENT_REFLECT_FAILED: "表 {table} 在探查范围内,但上次探查读不到它的结构(权限不够或对象有问题),修好后重新探查即可",
ABSENT_UNKNOWN: ("表 {table} 不在已探查的表里:升级前的探查只取了一部分表,无法判断它是否还在,请回到数据源卡片重新"
"「探查结构」"),
}
#: 说不出是哪一种「不在」时(上传表格、记录表按版本走;没有对象索引、又没截断过的老缓存认不出名字是不是从来就没有)
ABSENT_PLAIN = "数据源中没有表 {table}"
async def absent_message(session: AsyncSession, row: Any, table: str) -> str:
"""点名的表不在表结构里时给人看的一句(ABSENT_MESSAGE)。手工接入的源按 absent_reasons 说是哪一种;上传表格、记录表
的结构随版本走,只说没有(目录清单把它们记成 removed,是说「那一版里没有」,点名一个从来没有过的名字时说不得「已删除」);
没有对象索引的老缓存同理:absent_reasons 对没截断的一律记 removed,名字写错了也会这样,只说没有。"""
if (getattr(row, "origin", None) or "manual") != "manual":
return ABSENT_PLAIN.format(table=table)
reason = (await absent_reasons(session, row, [table])).get(table)
if reason == ABSENT_REMOVED and not await has_index(session, row.id, index_schema(row)):
reason = None
return ABSENT_MESSAGE.get(reason or "", ABSENT_PLAIN).format(table=table)
@dataclass(frozen=True)
class OutsideObject:
"""对象索引里在、范围外的一个对象(助手检索、目录搜索用)。"""
@@ -1039,7 +1066,8 @@ async def delete_index(session: AsyncSession, source_id: str) -> None:
__all__ = [
"ABSENT_MISSING", "ABSENT_OUT_OF_SCOPE", "ABSENT_REFLECT_FAILED", "ABSENT_REMOVED", "ABSENT_UNKNOWN", "ADD_LIMIT", "AddOutcome",
"ABSENT_MESSAGE", "ABSENT_MISSING", "ABSENT_OUT_OF_SCOPE", "ABSENT_PLAIN", "ABSENT_REFLECT_FAILED",
"ABSENT_REMOVED", "ABSENT_UNKNOWN", "ADD_LIMIT", "AddOutcome", "absent_message",
"INDEX_LIMIT", "IndexResult", "IndexedObject", "OutsideObject", "REFLECT_BATCH", "RULES_MAX", "Reflected",
"RefreshStats", "SCOPE_DEFAULT", "SCOPE_MAX", "SCOPE_OPTION", "SCOPE_REASONS", "ScopePlan", "ScopeRules", "Trail",
"VIA_CATALOG", "VIA_LOOK", "VIA_SCHEMA_TOOL", "absent_reasons", "add_to_scope", "apply_scope",
+124 -15
View File
@@ -111,7 +111,7 @@ PIPES_AS_CONCAT 的库这两处的意思不同,sqlglot 的 MySQL 读法和这
**具体说法(见 _Explainer)。** 只在两边的规范形式都是「简单形状」时说(_simple_block:整条语句就是一段 SELECT,没有
CTE、子查询、UNION 这类合并、窗口函数、DISTINCT ON;FROM 里只有实表,连接只有内连接、CROSS JOIN、逗号和 LEFT JOIN;
LIMIT 是至少为 1 的常数;没有认不准的名字、SELECT 列表里没有 sqlglot 不认识的函数;不认列的那一级里原文没有在
WHERE / GROUP BY 里用列别名的),而且只说四类:
WHERE / GROUP BY 里用列别名的),而且只说五类:
- 时间列(time):两边各在一张底层表的一列上按时间筛、列不同(另一边哪儿都没用这一列筛选,两边的时间条件都不是恒为
真的,时间列不在 LEFT JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见下),也说成
@@ -129,6 +129,13 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
不用到 LEFT JOIN 可空一侧的列(WHERE r.id IS NULL 是反连接,说的是「没关联上」;r.order_id IS NULL 和 ON 里的
r.order_id = o.id 一起已经蕴含 r.id IS NULL);不是可能的时间窗口(_window_like:date_key BETWEEN 20260901 AND
20260930、stat_month = 202609——认不准是不是时间列,说成有效行过滤会被写进数据目录)。detail 给条件涉及的实表列。
- 同一列上的条件改写(filter / time_range,a、b 两边都有):两边 WHERE 里对同一张实表的同一列各写了一套条件、留下的
取值不同——等号对 IN(口径 status = 'paid',这次 status IN ('paid', 'refunded'))、取值集合不同、范围的端点不同,说
「A 对 status 的条件是 …,B 是 …」,两边都只是取值集合、又有共同的取值时补「(B 多了 'refunded')」。要两边用到这一列
的条件都只涉及这一列、比的就是那一列本身,按取值逐段算过两边留下的取值确实不同(_Range.compare:表结构里确定不为空
的列不拿空值比;x >= 100 AND x >= 50 对 x >= 100 这类冗余写法算出来一样,不说),这一列不在 LEFT JOIN 的可空一侧,
两边在 WHERE 之外(ON、HAVING、CASE / FILTER、聚合的参数)都没用到这一列,不是认不准的时间窗口,没换表。两边都是
时间条件时 aspect 是 time_range。只改说明:走到这里时已经判了不等价(见 _Explainer._rewrites)。
- 聚合与去重(aggregate / group_by):选出来的列只差在顶层的聚合函数上(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))
或者去重上(COUNT / SUM / AVG 去不去重、COUNT(*) 对 COUNT(DISTINCT x)、SELECT DISTINCT 对不去重),分组一样。参数
得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键、表结构里它那张表的键没有在组里定死——按 u.id 分组
@@ -144,7 +151,7 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
每一句都先确认这处不同真会影响结果:两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件,同一个对象上取不到值的
几个条件)时什么都不说;结果看不见行的(SELECT COUNT(*) > -1 这类只出一行、值又和行无关的)不说筛选、关联上的不同;
表结构里的约束(主键、唯一约束、外键、NOT NULL)说明不起作用的不说——约束只拿来少说,不拿来判等价。被 WHERE 用常量定死
的列出现在排序、分组里的差异,恒为真的 HAVING,都不在四类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的
的列出现在排序、分组里的差异,恒为真的 HAVING,都不在五类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的
不说。
**兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异,
@@ -164,8 +171,9 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
关联先后、按 WHERE 把外连接改成内连接、换成相等的列;没给表结构(或者类型不一样)时按关联条件相等的两列换着分组、
排序;PostgreSQL、MySQL 没给表结构时去重、分组、求和的一段调换关联先后、并子查询、去掉没用到的 LEFT JOIN、把只取
可空一侧聚合的 LEFT JOIN 写成内连接。
- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);四类以外的不同(同一列上的时间
范围、同一列上换了筛选的值、关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数);几张表、几列交错的条件;
- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);五类以外的不同(算出来的值上的
条件改写——LOWER(status) = 'paid' 对 LOWER(status) IN (…)、和别的列交错的同一列条件、关联条件、分组、HAVING、排序、
取几行、选出来的列、聚合的参数);几张表、几列交错的条件;
自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的;条件写在聚合里的一边对条件写在 WHERE 里的一边(SUM(status
= 'paid') 对 COUNT(*) … WHERE status = 'paid':真正的不同是计数为 0 的组、空集时是 0 还是 NULL,说不清);反连接;
可能是时间窗口、又认不准是不是时间列的条件(业务日期键、统计月份、分区,第几天这类范围)。
@@ -5902,8 +5910,9 @@ class FacetDiff:
set_op(几支查询怎么合并)/ group_by(分组键、去重)/ having / limit(取哪几行,含取行前的排序)/ order(只是最终
结果的排序不同,不影响取到哪些行)/ parse(解析不了)/ other(兜底:未能归纳成具体差异,见 explain_sql_diff)。a、b
是两边各自的写法(给人看,没有就是 None),text 是一句中文说明。detail 只在 time 上有:两边的时间列
[{table, column}](各是各的表),调用方据此对照数据目录(业务日期);在 filter 上有 {"columns": [{table, column}]}:
条件只涉及的实表列,对照有效行过滤用。"""
[{table, column}](各是各的表),调用方据此对照数据目录(业务日期);在 filter、time_range 上有 {"columns":
[{table, column}]}:条件只涉及的实表列,对照有效行过滤用。filter 一边是 None 的是「一边多出来的条件」;两边都有的是
同一列上的条件两边各写了一套(「A 对 X 的条件是 …,B 是 …」),不是一边多出来的有效行条件。"""
aspect: str
a: Any
@@ -6360,8 +6369,10 @@ class _Range:
subject = n.this
return self._truth(n, assign[_fingerprint(subject)])
def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False) -> bool | None:
"""两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。"""
def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False,
never_null: bool = False) -> bool | None:
"""两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。
never_null:对象一定不为空(表结构里不为空的列),不拿空值比。"""
parsed = [self.subject(c) for c in conds_a + conds_b]
if any(p is None for p in parsed) or len({p[0] for p in parsed}) != 1:
return None
@@ -6373,7 +6384,7 @@ class _Range:
from sqlglot import exp
values = self._pieces(subject, literals, ranged, grouped)
if not isinstance(_unparen(subject), exp.Count):
if not isinstance(_unparen(subject), exp.Count) and not never_null:
values.append(None)
for v in values:
ta = all(self._truth(c, v) == "T" for c in conds_a)
@@ -6745,7 +6756,7 @@ def _alias_named(root: Any) -> bool:
class _Explainer:
"""在两边的规范形式上找有把握、又确实影响结果的不同,写成具体的中文句子,同时记下是不是所有不同都说到了。
只在两边都是简单形状时说(见 _simple_block),也只说下面四类;别的不同(同一列上的时间范围、同一列上换了筛选的值、
只在两边都是简单形状时说(见 _simple_block),也只说下面五类;别的不同(算出来的值上换了筛选的值、
关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数……)一律算没说到,交给兜底说明:
- time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选,时间列不在 LEFT
@@ -6757,6 +6768,8 @@ class _Explainer:
那一列(LOWER(x) <> 'PAID' 这类认不出取值范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定
不为空的列不算空值,见 _never_null);不用到 LEFT JOIN 可空一侧的列(反连接,见 _on_nullable_side);不是认不准
的时间窗口(见 _window_like)。
- filter / time_range(a、b 两边都有):同一列上的条件两边各写了一套、留下的取值确实不同(等号对 IN、取值集合不同、
范围的端点不同),说「A 对 X 的条件是 …,B 是 …」,见 _rewrites。
- aggregate / group_by:选出来的列只差在顶层的聚合函数(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))或者去重上
(COUNT / SUM / AVG 去不去重,COUNT(*) 对 COUNT(DISTINCT x),SELECT DISTINCT 对不去重),分组一样。参数在组里
定死的不说(被 WHERE / ON 定成常量、等于分组键、本身是常量、它那张表的键在组里定死了,见 _fixed_in_group);去重
@@ -7312,8 +7325,8 @@ class _Explainer:
def conditions(self, A: _Block, B: _Block, mapping: Mapping[str, str], skip_a: set[str],
skip_b: set[str]) -> bool:
"""WHERE:两边没配上的条件里说得准的两类——按哪一列筛时间不同(_times)、一边多出来的筛选条件(_extra_filters);
剩下的算没说到。"""
"""WHERE:两边没配上的条件里说得准的三类——按哪一列筛时间不同(_times)、同一列上的条件两边各写了一套(_rewrites)、
一边多出来的筛选条件(_extra_filters);剩下的算没说到。"""
xa = [c for c in A.where if self.fp(c) not in skip_a]
xb = [c for c in B.where if self.fp(c) not in skip_b]
only_a, only_b = self._unmatched(xa, xb, mapping)
@@ -7322,6 +7335,7 @@ class _Explainer:
if not self.visible:
return False
only_a, only_b, covered = self._times(A, B, only_a, only_b)
only_a, only_b = self._rewrites(A, B, xa, xb, only_a, only_b, mapping)
covered &= self._extra_filters(A, B, xa, only_a, 0)
covered &= self._extra_filters(B, A, xb, only_b, 1)
return covered
@@ -7361,6 +7375,101 @@ class _Explainer:
return [c for c in only_a if all(c is not x for x in conds_a)], \
[c for c in only_b if all(c is not x for x in conds_b)], True
def _rewrites(self, A: _Block, B: _Block, xa: list[Any], xb: list[Any], only_a: list[Any], only_b: list[Any],
mapping: Mapping[str, str]) -> tuple[list[Any], list[Any]]:
"""同一列上的条件两边各写了一套、留下的取值确实不同:说「A 对 X 的条件是 …,B 是 …」——等号对 IN(口径
status = 'paid',这次 status IN ('paid', 'refunded'))、取值集合不同、范围的端点不同。两边都只是「等于常量 / 在
常量列表里」、又有共同的取值时补一句各多了哪几个取值。说到了的从两边拿掉,说不准的原样还回去(交给
_extra_filters,多半是兜底)。
说得准要(每一条都是为了不说出不存在的不同):两边对这一列都有条件、至少一边有没配上的;两边 WHERE 里用到这一列的
条件都只涉及这一列(不和别的列交错)、比的就是那一列本身(不是 LOWER(x)),按取值逐段算过两边留下的取值不一样
(_Range.compare:表结构里确定不为空的列不拿空值比;认不出、算出来一样的不说——x >= 100 AND x >= 50 对 x >= 100
只是冗余的写法);这一列不在 LEFT JOIN 的可空一侧(那是关联上的事);两边在 WHERE 之外(ON、HAVING、CASE / FILTER、
条件里套着的聚合参数)都没用到这一列(SUM(CASE WHEN status = 'paid' …) 配上 WHERE status IN (…) 可能和只筛
'paid' 一样);不是认不准的时间窗口(_window_like:date_key BETWEEN 20260901 AND 20260930);没换表。两边的条件
都是时间条件(timed)时 aspect 是 time_range,否则是 filter(a、b 两边都有,dual_check_facts 不会把它当成「一边
多出来的有效行条件」写进数据目录)。
只改说明,不碰等价判定:走到这里时两边已经判了不等价(见 explain_sql_diff)。"""
if self.swapped or self.usage_a is None or self.usage_b is None:
return only_a, only_b
refs_a = [self.refs(c, A) for c in xa]
refs_b = [self.refs(c, B) for c in xb]
if any(r is None for r in refs_a + refs_b):
# 有认不出用到哪些列的条件:它可能也用到了这一列,说「A 对 X 的条件是 …」就不全
return only_a, only_b
keys: list[tuple[str, str]] = []
for c, block in [(c, A) for c in only_a] + [(c, B) for c in only_b]:
found = self.refs(c, block)
if found is not None and len(found) == 1 and next(iter(found)) not in keys:
keys.append(next(iter(found)))
for key in keys:
on_a = [c for c, r in zip(xa, refs_a) if key in r]
on_b = [c for c, r in zip(xb, refs_b) if key in r]
pending_a = [c for c in only_a if any(c is x for x in on_a)]
pending_b = [c for c in only_b if any(c is x for x in on_b)]
if not on_a or not on_b or not (pending_a or pending_b):
continue
if any(r != {key} for r in refs_a if r & {key}) or any(r != {key} for r in refs_b if r & {key}):
continue
if self._on_nullable_side(on_a, A) or self._on_nullable_side(on_b, B):
continue
mapped_b = [self.mapped(c, mapping) for c in on_b]
if not self._plain_subjects(on_a + mapped_b):
continue
times = {self.timed(c, A) for c in on_a} | {self.timed(c, B) for c in on_b}
if times != {key} and any(_window_like(c) for c in on_a + on_b):
continue
kinds = _kinds_for(on_a + on_b)
if self.usage_a.touches({key}, skip=on_a, kinds=kinds) or \
self.usage_b.touches({key}, skip=on_b, kinds=kinds):
continue
never_null = _not_null_column(self.info, key[0], key[1], self.read)
if self.range.compare(on_a, mapped_b, never_null=never_null) is not False:
continue
shown_a, shown_b = self.show_all(_by_bound(on_a), A), self.show_all(_by_bound(on_b), B)
text = _say(self.la, "对", self.named(*key, qualified=len(A.all_rels()) > 1), "的条件是", shown_a, ",",
self.lb, "是", shown_b)
more = self._members(on_a), self._members(mapped_b)
# 两边都只是取值集合、又有共同的取值时补一句各多了哪几个(两边的取值毫不相干时前一句已经说全了)
if more[0] is not None and more[1] is not None and set(more[0]) & set(more[1]):
extra_a = [shown for value, shown in more[0].items() if value not in more[1]]
extra_b = [shown for value, shown in more[1].items() if value not in more[0]]
parts = [_say(label, "多了", "、".join(extra)) for label, extra in ((self.la, extra_a),
(self.lb, extra_b)) if extra]
if parts:
text = _say(text, "(", ",".join(parts), ")")
self.add("time_range" if times == {key} else "filter", shown_a, shown_b, text,
{"columns": [{"table": key[0], "column": key[1]}]})
only_a = [c for c in only_a if all(c is not x for x in on_a)]
only_b = [c for c in only_b if all(c is not x for x in on_b)]
return only_a, only_b
def _members(self, conds: list[Any]) -> dict[Any, str] | None:
"""几个条件(AND 连)都是「一列等于常量 / 在常量列表里」时,留下的取值 {比较用的值(见 _Range._norm): 给人看的
写法};有别的写法(比大小、不等于、NOT、OR、列表里的 NULL)返回 None。"""
from sqlglot import exp
kept: dict[Any, str] | None = None
for cond in conds:
cond = _unparen(cond)
if isinstance(cond, exp.EQ):
items = [cond.expression] if _literal_value(cond.expression) is not None else [cond.this]
elif isinstance(cond, exp.In) and cond.expressions and not cond.args.get("query") and \
not cond.args.get("unnest") and not cond.args.get("field"):
items = list(cond.expressions)
else:
return None
values: dict[Any, str] = {}
for item in items:
value = _literal_value(item)
if value is None:
return None
values.setdefault(self.range._norm(value), self.sql(item))
kept = values if kept is None else {v: shown for v, shown in kept.items() if v in values}
return kept
def _extra_filters(self, block: _Block, other: _Block, where: list[Any], rest: list[Any], side: int) -> bool:
"""block 这一边多出来的筛选条件(rest 是它没配上的 WHERE 项)。按用到的列连成一组(x > 5 和 x IS NOT NULL
是一组)一组一组看,说得准的说「A 只保留 … 的行,B 没有这个条件」「A 排除了 … 的行,B 没有」「A 按 X 筛时间,
@@ -7943,9 +8052,9 @@ def explain_sql_diff(a: str, b: str, dialect: str | None = None, *, schema: Mapp
一元加号、0x10、换了名字的类型名,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字……,见
_lost_in_parsing、_misread)不在语法树上判等价,只给兜底和原文的逐行对照。
不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把四类有把握、
真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联
类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体
不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把五类有把握、
真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和
去重、关联的表和关联类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体
差异,请对照原文」),只覆盖了一部分时追加一条「另有未能归纳的差异」,fallback 为真;text_diff 给出规范化之后不同
的行(没有规范形式可比时给原文的逐行对照)。
+18 -8
View File
@@ -13,9 +13,17 @@
记 info,列出变化最大的几个取值。
- **断档**(date_stalled):最新业务日期没前进,且这个日期离今天超过 max(2 × 以前前进的中位间隔, 3 天);要见过至少一次
前进才知道它多久前进一次。
- **缺天**(date_gap):窗口里(有数据的第一天到最后一天之间)有日子没数据,而以前同口径的窗口都没有过。
- **缺天**(date_gap):窗口里(有数据的第一天到最后一天之间)有日子没数据,而以前同口径的窗口都没有过。只看到观察当天
为止:业务日期有未来值(预约、计划类的日期,或者写错的)时,今天以后的空日子不是「没数据」,那几天本来就还没到;以前
那次观察也只看到它自己那天(data.until)为止,不然它窗口里未来的空日子会被当成「以前就有过缺天」,挡住这一次真缺的天。
- **量跌**(volume_drop):最近一个整天的行数不到它前面 28 天中位数的 30%(前 28 天至少 14 天有数据、中位数至少 20 行)。
最后一天和观察当天同一天(还在写入)时往前退一天。只看这一次的窗口,不需要历史。
最近一个整天是「有数据的最后一天」和「观察当天的前一天」里早的那个:观察当天还在写入,不是整天;业务日期有未来值时
有数据的最后一天在今天以后,也不能拿它退一天(那样会退到今天这个不完整的日子上)。只看这一次的窗口,不需要历史。
- **断档**遇到未来值:最新业务日期在今天以后时离今天是负数,不会报断档(报不出、也不误报);观察查询本身不截未来值
(最新业务日期照实记,目录里也照实给人看)。
「今天」由调用方传进来(today):取值观察按服务端所在时区算(data/catalog_profile_observe.local_today),业务日期是本地
日期,用 UTC 的日期在本地凌晨会差一天。
- **行数**(rows_drop / rows_jump):比上一次同口径的少了 30% 以上 / 涨到 3 倍以上;依据是数据库统计的估算值时,要连续
两次都这样(和跌、涨之前那一次比)才报。数到上限、只知道「至少多少行」的不比。
"""
@@ -177,7 +185,10 @@ def _series(data: Mapping[str, Any]) -> dict[date, int]:
return out
def _holes(days: Mapping[date, int]) -> list[date]:
def _holes(days: Mapping[date, int], until: date | None = None) -> list[date]:
"""有数据的第一天到最后一天之间没数据的日子;until 给了时只看到这一天为止(今天以后的空日子不算缺天)。"""
if until is not None:
days = {d: n for d, n in days.items() if d <= until}
if not days:
return []
first, last = min(days), max(days)
@@ -190,15 +201,14 @@ def daily(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]
same, _note = comparable(current, history)
days = _series(current.data)
out: list[Finding] = []
holes = _holes(days)
if holes and same and all(not _holes(_series(h.data)) for h in same):
holes = _holes(days, today)
if holes and same and all(not _holes(_series(h.data), _day(h.data.get("until"))) for h in same):
out.append(Finding("date_gap", "warning", before={"gaps": 0},
after={"gaps": len(holes), "days": [d.isoformat() for d in holes[:GAPS_SHOWN]]},
key=tuple(d.isoformat() for d in holes)))
if days:
last = max(days)
if last >= today:
last -= timedelta(days=1)
# 最近一个整天:有数据的最后一天和昨天里早的那个(今天还在写入;未来的日子不是整天)
last = min(max(days), today - timedelta(days=1))
base = [days.get(last - timedelta(days=i), 0) for i in range(1, VOLUME_BASELINE_DAYS + 1)]
present = sum(1 for n in base if n > 0)
median = statistics.median(base) if base else 0
+15 -3
View File
@@ -52,6 +52,8 @@ END_REJECTED = "rejected"
END_STOPPED = "stopped"
#: 被驳回的运行不能继续运行:从断点重跑会读到同一个驳回答复、再停在同一处
REJECTED_CONTINUE = "被驳回的运行不能继续运行:改好内容后重新发起"
#: 被驳回的运行也没有可恢复的审批(恢复、继续运行同一个错误码 RUN_REJECTED)
REJECTED_RESUME = "这次运行已被驳回,没有待处理的审批,无法恢复:改好内容后重新发起"
#: 服务重启时还在排队的运行改成失败时写的话(run.error 和补发的 run.failed 是同一句)
QUEUED_AT_RESTART = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行"
@@ -355,7 +357,7 @@ class RunManager:
if not run:
raise KeyError("运行记录不存在")
if run.status not in ("interrupted",):
raise ValueError(_cannot_resume(run.status))
raise await _resume_refused(session, run)
spec = GraphSpec.model_validate(run.graph)
workflow_id = run.workflow_id
carried = _carried(run)
@@ -458,8 +460,8 @@ class RunManager:
await session.commit()
run = await session.get(Run, run_id)
if run.status != "interrupted":
# 审批没了是因为运行在这期间被放弃了:不能回一个「已记下」
raise ValueError(_cannot_resume(run.status))
# 审批没了是因为运行在这期间被放弃了(或被别人驳回了):不能回一个「已记下」
raise await _resume_refused(session, run)
return run
# 齐了:正式落 resolved,然后驱动引擎。谁批的跟着交给节点,
@@ -1186,6 +1188,16 @@ _STATUS_TEXT = {
}
async def _resume_refused(session: Any, run: Run) -> ValueError:
"""恢复被拒时抛什么:被驳回的(cancelled + end_reason=rejected,以及加 end_reason 之前记成 failed 的驳回)说「被驳回」、
带 RUN_REJECTED,和继续运行同一个错误码(以前照状态说「已取消的运行请重新发起」、对存量的还叫人点「继续运行」,
可继续运行也会被拒);别的照状态说(_cannot_resume)。"""
if (run.status == "cancelled" and run.end_reason == END_REJECTED) or (
run.status == "failed" and await _rejected_before(session, run)):
return ContinueRefused(REJECTED_RESUME, code=RUN_REJECTED)
return ValueError(_cannot_resume(run.status))
def _cannot_resume(status: str) -> str:
hint = {
"running": "运行仍在进行,无需恢复。",
+47 -15
View File
@@ -708,20 +708,26 @@ class EvalExecution(runtime.JobRun):
.execution_options(synchronize_session=False))
await session.commit()
# 停止、中断是「停下」不是「出错」:原因记 stop_reason(error 留空);整体出错记 error
stopped = status in ("cancelled", "interrupted")
try:
await self._write(write)
if not self.arms:
async def bare(session: AsyncSession) -> None:
await session.execute(update(EvalRun).where(
ids = list((await session.execute(select(EvalRun.id).where(
(EvalRun.id == self.job_id) | (EvalRun.pair_id == self.job_id),
EvalRun.status.in_(runtime.ACTIVE))
.values(status=status, error=error, finished_at=utcnow())
.execution_options(synchronize_session=False))
EvalRun.status.in_(runtime.ACTIVE)))).scalars())
await session.execute(update(EvalRun).where(EvalRun.id.in_(ids))
.values(status=status, error=None if stopped else error,
stop_reason=error if stopped else None, finished_at=utcnow())
.execution_options(synchronize_session=False))
for run_id in ids:
await _recount(session, run_id)
await session.commit()
await self._write(bare)
return
await self._conclude(status, None, error)
await self._conclude(status, error if stopped else None, None if stopped else error)
except Exception: # noqa: BLE001
logger.exception("评测 %s 收尾时写库出错", self.job_id)
@@ -793,16 +799,31 @@ def _sql_shapes(answer: Any) -> list[dict[str, Any]]:
return out
#: 逐项结束了、却没有跑出结果的状态:cancelled 停止、中断时还没评完的,skipped 到了金额上限、止损之后没开评的
NOT_RUN_STATUSES = ("cancelled", "skipped")
def _counts(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]:
out = {"total": len(rows), "done": 0, "running": 0, "pending": 0, "passed": 0, "failed": 0, "ungradable": 0,
"skipped": 0, "errors": 0}
"""一臂的计数(EvalRun.counts)。几样各管各的,加起来不重:
- done:评完了的(ok、partial)和评的时候出错的(failed)——真的跑过的;running、pending 是还在评、还没轮到的;
- not_run:停止、中断时还没评完,到了上限、止损之后没开评的(NOT_RUN_STATUSES)。以前这些算进 done 和 errors,停下的
评测就成了 done = total、一堆「出错」,读起来像全评完了、出了很多错;
- errors:出错的(failed:题目没了、评的时候出了意外),不含没跑的;
- passed / failed / ungradable / skipped:评完了的按结论分(skipped 是评了、但期望算不出、不评分,不含没开评的)。
done + not_run + running + pending = total。"""
out = {"total": len(rows), "done": 0, "running": 0, "pending": 0, "not_run": 0, "passed": 0, "failed": 0,
"ungradable": 0, "skipped": 0, "errors": 0}
for r in rows:
status = r.get("status")
if status in runtime.TASK_DONE:
if status in NOT_RUN_STATUSES:
out["not_run"] += 1
elif status in runtime.TASK_DONE:
out["done"] += 1
elif status in ("running", "pending"):
out[status] += 1
if status in ("failed", "cancelled"):
if status == "failed":
out["errors"] += 1
verdict = r.get("verdict")
if verdict == "pass":
@@ -811,11 +832,21 @@ def _counts(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]:
out["failed"] += 1
elif verdict == "ungradable":
out["ungradable"] += 1
elif verdict == "skipped" or status == "skipped":
elif verdict == "skipped":
out["skipped"] += 1
return out
def _closing(run_id: str, reason: str) -> runtime.Finish:
"""runtime.close_job / cancel_queued 的 finish:同一个事务里记停下的原因(停止、中断),按改好的逐项状态重算计数。"""
async def finish(session: AsyncSession) -> None:
await session.execute(update(EvalRun).where(EvalRun.id == run_id).values(stop_reason=reason)
.execution_options(synchronize_session=False))
await _recount(session, run_id)
return finish
async def _recount(session: AsyncSession, run_id: str) -> None:
rows = (await session.execute(select(EvalResult.status, EvalResult.verdict).where(
EvalResult.eval_run_id == run_id))).all()
@@ -910,16 +941,16 @@ async def reconcile_stale(session: AsyncSession, rows: Sequence[EvalRun]) -> Non
"""读接口当场整理:记着排队、进行中,这个进程里却没在跑(服务重启过)的,照实标中断。"""
stale = [r for r in rows if r.status in runtime.ACTIVE and not running(r)]
for row in stale:
await runtime.close_job(session, EvalRun, EvalResult, row.id, "interrupted", INTERRUPTED, "cancelled",
INTERRUPTED)
await runtime.close_job(session, EvalRun, EvalResult, row.id, "interrupted", None, "cancelled",
INTERRUPTED, finish=_closing(row.id, INTERRUPTED))
await session.refresh(row)
async def startup() -> int:
"""服务启动时:没结束的评测标中断(没结束的项记停止),不自动续跑。"""
async def close(session: AsyncSession, job_id: str) -> None:
await runtime.close_job(session, EvalRun, EvalResult, job_id, "interrupted", INTERRUPTED, "cancelled",
INTERRUPTED)
await runtime.close_job(session, EvalRun, EvalResult, job_id, "interrupted", None, "cancelled",
INTERRUPTED, finish=_closing(job_id, INTERRUPTED))
return await runtime.interrupt_on_startup(EvalRun, close, running=manager.running)
@@ -938,7 +969,8 @@ async def stop(run_id: str) -> bool:
(EvalRun.id == leader) | (EvalRun.pair_id == leader)))).scalars())
done = False
for arm in arms:
done = await runtime.cancel_queued(session, EvalRun, EvalResult, arm) or done
done = await runtime.cancel_queued(session, EvalRun, EvalResult, arm,
finish=_closing(arm, STOPPED)) or done
return done
+5 -4
View File
@@ -1,8 +1,8 @@
"""评测的汇总与统计(细案 01 做法 11、12;评测-D10)。所有汇总都在后端算好,界面直接显示(界面层不放逻辑:同一个数不能
有两套算法)。纯计算,输入是一次评测的逐题结果(EvalResult 的列,result_row 的形状)。
- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没开评、停下、出错的
另列 not_run。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。
- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没跑的(停止、中断、
到了上限、止损没开评)另列 not_run,出错的另列 errors。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。
- 作答路径、出具档位(声明了出具的按 formal / degraded / withheld,没声明的单列「探索问答」)、复核结论(verdict × severity)
和自动重来的次数、复核用时超过问数据页等待(25 秒)的题数。
- 结论裁判(打开了才有):supported / partial / contradicted / insufficient / 未判的句数;文字要点的判断单列。
@@ -79,7 +79,7 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) -
from app.eval.score import SCORER_VERSION
done = [r for r in results if r.get("status") in ("ok", "skipped", "failed", "cancelled", "partial")]
#: 评完了的(status=ok);没开评、停下、出错的单列成 not_run
#: 评完了的(status=ok);没跑的(停止、中断、到了上限、止损没开评)单列成 not_run,出错的单列成 errors(同 runner._counts)
evaluated = [r for r in done if r.get("status") == "ok"]
verdicts = Counter(r.get("verdict") or "none" for r in done)
graded = [r for r in done if r.get("verdict") in ("pass", "fail")]
@@ -118,7 +118,8 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) -
"items": len(results), "done": len(done),
"cases": len({r.get("case_id") for r in results}),
"verdicts": {k: verdicts.get(k, 0) for k in ("pass", "fail", "ungradable", "skipped")},
"not_run": len(done) - len(evaluated),
"not_run": sum(1 for r in done if r.get("status") in ("cancelled", "skipped")),
"errors": sum(1 for r in done if r.get("status") == "failed"),
"accuracy": rate(len(passed), len(graded)),
"traced_accuracy": rate(len(traced), len(graded)),
"outcomes": dict(outcomes),
+50 -10
View File
@@ -11,13 +11,16 @@
个问题的类;
- 被反复用的答案(reused):导出过 ≥ 2 次(report.exported 审计,第 2 波)、做成过应用(蓝图的来源是这次运行)、在画布里
打开过或提取过(工作流的 origin.run_id 是这次运行)的轮次。
每项给代表问题、轮次链接、(shape 的)SQL 形状和参数候选、有没有相关口径(可以对账的那份)、是不是已经固化过。点「不是口径」
每项给代表问题、轮次链接、(shape 的)SQL 形状和参数候选、有没有相关口径(可以对账的那份)、是不是已经固化过(frozen_into,
同一个工作流只列一次)。点「不是口径」
写一条审计(ledger.certify.dismiss,subject 是这一项的 id),之后不再进队。门槛同 ledger/stats(E3 的重复度)。
**固化成口径草稿**(freeze,from-run 加 freeze 选项后转调它):代表运行里报告引用了的查询,各变成一个调用工具节点——SQL 用
规范化后的文本,跨轮变化的字面量换成 '{{ input.p1 }}'(同组的别的运行 peers 用来认哪些位置变了),入口字段的缺省值取
代表运行里的值;只有一行的结果每个数值列建一项口径卡指标(cell(nodes.取数, 0, '列'));报告节点保留,出口契约写
report_from + metrics_from(有多行结果的另加 cells: true),过 validate_graph。新工作流记 origin.sources:
代表运行里的值;只有一行的结果每个数值列建一项口径卡指标(cell(nodes.取数, 0, '列'),指标名能从数据目录取到中文名的
用中文名,取不到用列名);口径卡的名字用传入的 name,没传用代表问题;报告节点保留,指令在有入口参数时不写代表那一轮的问题
原文(里面的日期参数化之后就不对了),改写运行时渲染的参数,没有参数时照旧写问题原文;出口契约写 report_from +
metrics_from(有多行结果的另加 cells: true),过 validate_graph。新工作流记 origin.sources:
[{run_id, conversation_id, turn_id, question}],这就是「口径 → 来源问题」的记录。之后由人在画布上审改、按受管级别发布——
提取器是作者,不是发布者。
@@ -64,6 +67,9 @@ DRAFT_NAME = "{question}(固化的口径草稿)"
DRAFT_DESCRIPTION = ("从问数据的回答固化:报告引用了的 {n} 条查询变成固定的取数,跨轮变化的 {p} 个字面量成为入口参数。"
"经人工审核、按受管级别发布后,再问同样的问题就能认出等价、按口径出具")
REPORT_INSTRUCTIONS = "回答:{question}"
#: 有入口参数时的报告指令:不写代表那一轮的问题原文——里面的日期(「上周(10月5日到11日)」「九月第一周」)参数化之后
#: 就不对了;改写这次运行时渲染出来的参数。{params} 是「p1 = {{ input.p1 }},…」
REPORT_INSTRUCTIONS_PARAMS = "按取到的数回答这个口径的问题(这次的参数:{params};时间范围、筛选的取值以参数为准)"
class CertifyRefused(Exception):
@@ -228,6 +234,16 @@ async def _frozen(session: Any) -> dict[str, list[dict[str, Any]]]:
return out
def _frozen_of(run_ids: Sequence[str | None], frozen: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""这几次运行固化进了哪些工作流:同一个工作流只列一次(固化时同组的运行都记进 origin.sources,一项里的几次运行常常
指着同一个工作流),按先后。"""
out: dict[str, dict[str, Any]] = {}
for run_id in run_ids:
for w in frozen.get(run_id or "", []):
out.setdefault(str(w["workflow_id"]), w)
return list(out.values())
def _turn_view(member: Any) -> dict[str, Any]:
return {"run_id": member.run_id, "conversation_id": member.conversation_id, "turn_id": member.turn_id,
"question": member.question}
@@ -308,7 +324,7 @@ async def queue(session: Any, *, principal: Any) -> dict[str, Any]:
"constants": len(rep.literals) - len(params), "related": related,
"representative": {"run_id": rep.run_id, "query": rep.artifact},
"peers": [m.run_id for m in once[1:]],
"frozen_into": [w for m in once for w in frozen.get(m.run_id, [])],
"frozen_into": _frozen_of([m.run_id for m in once], frozen),
})
# 问题聚类
questions = await recent_questions(session, principal, limit=QUESTION_LIMIT)
@@ -322,7 +338,7 @@ async def queue(session: Any, *, principal: Any) -> dict[str, Any]:
items.append({"id": iid, "kind": "cluster", "question": seed.text, "count": cluster.count,
"turns": [{"run_id": q.run_id, "turn_id": q.turn_id, "question": q.text}
for q in cluster.members[:TURNS_LIMIT]],
"frozen_into": [w for q in cluster.members if q.run_id for w in frozen.get(q.run_id, [])]})
"frozen_into": _frozen_of([q.run_id for q in cluster.members], frozen)})
items += await _reused(session, runs, turns, dismissed, frozen)
return {"items": items, "considered": {"runs": len(runs), "matched": len(latest), "questions": len(questions)},
"threshold": {"runs": REPEAT_RUNS, "conversations": REPEAT_CONVERSATIONS, "exports": EXPORTS},
@@ -367,7 +383,7 @@ async def _reused(session: Any, runs: list[Run], turns: dict[str, dict[str, Any]
out.append({"id": iid, "kind": "reused", "question": info.get("question") or "", "uses": uses,
"turns": [{"run_id": run.id, "conversation_id": info.get("conversation_id"),
"turn_id": info.get("turn_id"), "question": info.get("question") or ""}],
"representative": {"run_id": run.id}, "frozen_into": frozen.get(run.id, [])})
"representative": {"run_id": run.id}, "frozen_into": _frozen_of([run.id], frozen)})
return out
@@ -385,6 +401,24 @@ async def dismiss(session: Any, iid: str, *, kind: str | None = None, note: str
# ==========================================================================
async def _labels(session: Any, snap: dict[str, Any], source: str) -> Any:
"""结果列的中文名:先看查询当时冻结的数据目录(快照的 schema_artifact,同口径比对),没冻结的(老快照)看数据源现在
的目录。只认列名本身就是目录里的一列(SELECT amount、SUM(amount) AS amount);算出来起的别名(gmv)取不到,调用方
照旧用列名。返回 _labeler:(表, 列) → 中文名或 None。"""
from app.data import catalog
from app.data.sql_semantics import _labeler, catalog_labels
from app.db.models import DataSource
from app.ledger.match import _frozen as frozen_schema
_, names = frozen_schema(snap.get("schema_artifact"))
if not names:
source_id = (await session.execute(select(DataSource.id).where(DataSource.name == source))).scalar()
if source_id:
entries = await catalog.read_catalog(session, source_id)
names = catalog_labels({table: entry.notes for table, entry in entries.items()})
return _labeler(names)
async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name: str = "",
conversation_id: str | None = None, turn_id: str | None = None) -> dict[str, Any]:
"""把一次问数据运行固化成口径草稿(见模块说明),落库成新的草稿工作流,返回 {workflow_id, name, nodes, edges, params,
@@ -460,9 +494,10 @@ async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name:
rows = snap.get("rows") if isinstance(snap.get("rows"), list) else []
columns = [str(c) for c in snap.get("columns") or []]
if len(rows) == 1 and isinstance(rows[0], (list, tuple)):
label = await _labels(session, snap, shape.source)
for col, value in zip(columns, rows[0]):
if isinstance(value, (int, float)) and not isinstance(value, bool):
metrics.append({"id": f"m{len(metrics) + 1}", "name": col,
metrics.append({"id": f"m{len(metrics) + 1}", "name": label((None, col)) or col,
"expression": f"cell(nodes.{nid(i)}, 0, {col!r})"})
else:
multi = True
@@ -473,15 +508,20 @@ async def freeze(session: Any, run_id: str, *, peers: Sequence[str] = (), name:
found = next((n for n in (run.graph or {}).get("nodes") or [] if n.get("id") == reports[0].node_id), None)
cfg = ((found or {}).get("data") or {}).get("config") or {}
report_cfg = {k: cfg[k] for k in ("provider", "model", "entities") if k in cfg}
report_cfg["instructions"] = REPORT_INSTRUCTIONS.format(question=question or "(问题)")
if fields:
report_cfg["instructions"] = REPORT_INSTRUCTIONS_PARAMS.format(
params=",".join(f"{f['name']} = {{{{ input.{f['name']} }}}}" for f in fields))
else:
# 没有入口参数:取数的字面量都是口径的常量,问题原文里的日期和它们对得上
report_cfg["instructions"] = REPORT_INSTRUCTIONS.format(question=question or "(问题)")
# 报告的核对规则一律按受管级别的要求写(engine/governance.REPORT_POLICY,和发布门禁同一份):草稿要能按受管发布
report_cfg.update({key: value for key, value, _ in REPORT_POLICY})
contract: dict[str, Any] = {"report_from": "write"}
tail: list[dict[str, Any]] = []
if metrics:
tail.append({"id": "card", "type": "metrics", "position": {"x": 0, "y": 0},
"data": {"label": "口径卡", "config": {"caliber": (question or "口径")[:40], "caliber_version": "v1",
"metrics": metrics}}})
"data": {"label": "口径卡", "config": {"caliber": (name.strip() or question or "口径")[:40],
"caliber_version": "v1", "metrics": metrics}}})
report_cfg["metrics_from"] = ["card"]
# 受管出具要写必需指标(不然「不予出具」这一档触发不了):固化出的每个指标都是这个口径要给的数
contract.update({"metrics_from": "card", "required": [m["id"] for m in metrics]})
+24 -3
View File
@@ -17,7 +17,8 @@
段对话的算重复的形状,给出这样的形状几个、有重复形状的运行占几成;问题聚类(app/chat/questions:全站唯一的聚类,只取问题
原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(第 3 波,ledger/certify):固化成
口径草稿几份(origin.freeze 的工作流)、其中发布了几份、点了「不是口径」几项。
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数);每次发布(口径升到某一版)召回了几组、
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数——只算图上有源头口径卡的版本,没有口径卡
的普通发布工作流的正式运行不算);每次发布(口径升到某一版)召回了几组、
收过尾的几组、其中数变了的几组和比例、重算失败的几组(ledger/recall:召回运行和它收尾时记的 ledger.recalled,第 4 波)。
按召回运行的发起时间筛窗口;表结构变了之后的口径试算不算召回。
"""
@@ -289,8 +290,10 @@ async def _e4(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
formal = {ledger_labels.caliber_key(w, v): int(n)
for w, v, n in (await session.execute(stmt.group_by(Run.workflow_id, Run.version))).all()}
counted = {(w, v): int(n) for w, v, n in (await session.execute(stmt.group_by(Run.workflow_id, Run.version))).all()}
# 只算有口径卡的那几版:没有口径卡的普通发布工作流的正式运行不是「这个口径版本被用了」
calibers = await _caliber_versions(session, counted)
formal = {ledger_labels.caliber_key(w, v): n for (w, v), n in counted.items() if (w, v) in calibers}
recalls = int(await session.scalar(select(func.count()).select_from(Run).where(Run.origin_kind == "recall")) or 0)
usage = [{"caliber": key, "workflow_id": key.rsplit("@", 1)[0], "version": int(key.rsplit("@", 1)[1]),
"proven_runs": proven.get(key, 0), "formal_runs": formal.get(key, 0)}
@@ -298,6 +301,24 @@ async def _e4(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
return {"usage": usage, "recall_runs": recalls, "per_publish": await _per_publish(session, **scope)}
async def _caliber_versions(session: Any, keys: Collection[tuple[str, int]]) -> set[tuple[str, int]]:
"""这几个(工作流, 版本)里图上有源头口径卡的那些(recall.source_cards:metrics 节点、不是转抄别处的 caliber_from)。"""
from sqlalchemy import or_
from app.db.models import WorkflowVersion
from app.ledger.recall import source_cards
keys = list(keys)
out: set[tuple[str, int]] = set()
for i in range(0, len(keys), 200):
chunk = keys[i:i + 200]
rows = (await session.execute(select(WorkflowVersion.workflow_id, WorkflowVersion.version, WorkflowVersion.graph)
.where(or_(*[(WorkflowVersion.workflow_id == w) & (WorkflowVersion.version == v)
for w, v in chunk])))).all()
out |= {(w, v) for w, v, graph in rows if source_cards(graph)}
return out
async def _per_publish(session: Any, **scope: Any) -> dict[str, Any]:
"""每次发布召回了几组、其中数变了的比例(E4 下半):按(工作流、新版本)分,读召回运行收尾时记的结论。"""
from app.core.events import EventType
+19 -6
View File
@@ -342,6 +342,18 @@ async def notify_task(task_id: str, event: str, *, users: list[str], exclude: fr
task_id=task_id)
def _answers_text(uses: list[dict[str, Any]]) -> str:
"""召回消息里「答案」那一行:按种类分开写条数(「问数回答 1 个、正式运行 1 个」,种类的叫法同版本用量 ledger.uses.KIND_TEXT),
只写条数、不放数。以前合成一个「2 个」,一个问数回答加一次正式运行读起来像两个问数回答。一个都没有写「0 个」。"""
from collections import Counter
from app.ledger.uses import KIND_TEXT, KINDS
counts = Counter(str(u.get("kind") or "") for u in uses)
order = [k for k in KINDS if counts.get(k)] + sorted(k for k in counts if k not in KINDS)
return "、".join(f"{KIND_TEXT.get(k, k or '答案')} {counts[k]} 个" for k in order) or "0 个"
async def notify_recall(task_id: str, users: list[str]) -> int:
"""口径召回的待办开出来了(app/ledger/recall):口径升版、数真变了的那一种推 ledger.recall——订阅了它的全局渠道、答案主人
自己的个人渠道;然后照别的待办推 task.opened(已经推过 ledger.recall 的渠道不再推,同一个渠道只推一次)。目录、表结构变了
@@ -352,8 +364,9 @@ async def notify_recall(task_id: str, users: list[str]) -> int:
答案、待办按这一批汇总,链到待办箱(各人在那里看到派给自己的那条);别的几条连 task.opened 也不再推全局渠道。个人渠道
照旧每个主人各一条,只数他的答案、链到他的那条待办。
消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(几条待办)、召回编号、去处理的链接;不放
旧口径的值、新口径的值、差值和原答案的值。返回推了几个渠道。"""
消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(按种类分开写条数:问数回答几个、正式运行
几个……,见 _answers_text;几条待办)、召回编号、去处理的链接;不放旧口径的值、新口径的值、差值和原答案的值。返回推了几个
渠道。"""
from app.ledger import recall
try:
@@ -387,13 +400,13 @@ async def notify_recall(task_id: str, users: list[str]) -> int:
head = [("状态", "待处理"), ("口径", f"「{name[:80]}」"),
("版本", f"v{origin.get('from_version')} → v{origin.get('to_version')}")]
tail = [("召回", f"#{task.run_id[:6]}")]
mine = len(recall._uses_of(origin, recall._owner_of_key(key)))
everyone = len(recall._uses_of(origin, ("anyone", "")))
mine = _answers_text(recall._uses_of(origin, recall._owner_of_key(key)))
everyone = _answers_text(recall._uses_of(origin, ("anyone", "")))
def message(answers: int, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]:
def message(answers: str, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]:
return {"event": event, "status": "pending", "ok": False,
"title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")),
"lines": [*head, ("答案", f"{answers} 个"), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail],
"lines": [*head, ("答案", answers), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail],
"links": [link] if base and link else [],
"subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}}
+6
View File
@@ -126,6 +126,12 @@ class MockChatModel(BaseChatModel):
def _llm_type(self) -> str:
return "mock"
@property
def _identifying_params(self) -> dict[str, Any]:
"""调用参数里带上模型名(同 OpenAI 兼容、Anthropic 的 model):用量计量(providers/usage)按它记在哪个模型名下,
不报的话评测汇总里助手用量的模型就成了 unknown。"""
return {"model_name": self.model_name}
def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "MockChatModel":
normalized: list[dict[str, Any]] = []
for tool in tools:
+8 -2
View File
@@ -18,8 +18,10 @@ async generator:with 块跨着 yield 的话,变量在两次 yield 之间留
流末尾那一段用量收得到;只有模型在那一行之后还往下写超过 DRAIN_CHARS 字的闲话时才当场关流(不为闲话付钱),连接断了也一样,
这一次只拿得到已经报了的部分:照样计次数、计已报的 token,记成估不出,不悄悄少算。
模型名按发起调用时的参数取(invocation_params 的 model / model_name,就是接入里配的模型 id,价格目录按它查),取不到再
看回复里的 model_name。
模型名按发起调用时的参数取(invocation_params 的 model / model_name,就是接入里配的模型 id,价格目录按它查);调用参数里
没有的(以前的演示模型就没报,评测汇总里助手用量的模型因此记成 unknown),看 LangChain 照模型对象给回调的 metadata 里的
ls_model_name;再取不到看回复里的 model_name,都没有才记 UNKNOWN_MODEL。OpenAI 兼容、Anthropic 的模型在调用参数里就带着
model,演示模型(providers/mock_model)也把 model_name 报进调用参数。
"""
from __future__ import annotations
@@ -54,6 +56,10 @@ class Meter(BaseCallbackHandler):
def _started(self, run_id: UUID, serialized: dict[str, Any] | None, kwargs: dict[str, Any]) -> None:
params = kwargs.get("invocation_params") or {}
model = params.get("model") or params.get("model_name") or params.get("model_id")
if not model:
# LangChain 照模型对象给回调的 metadata 里带着 ls_model_name(调用时传的 model,否则对象上的 model /
# model_name):调用参数里不报模型名的模型类也认得出
model = (kwargs.get("metadata") or {}).get("ls_model_name")
if not model and isinstance(serialized, dict):
model = (serialized.get("kwargs") or {}).get("model") or (serialized.get("kwargs") or {}).get("model_name")
with self._lock:
+7 -1
View File
@@ -968,12 +968,18 @@ async def _on_demand(source: Any, missing: list[str], ctx: ToolContext | None) -
from app.db.base import SessionLocal
from app.look.base import ON_DEMAND_PER_LOOK, ON_DEMAND_PER_RUN
from app.db.models import Run
out: dict[str, str] = {}
lines: list[str] = []
starter: str | None = None
try:
async with SessionLocal() as session:
reasons = await so.absent_reasons(session, source, missing)
used = await so.count_added_in_run(session, ctx.run_id) if ctx is not None and ctx.run_id else 0
# 留痕记上运行的发起人(同 look 那条路记对话里的发起人):不然只有 run_id,要回头去翻运行才知道是谁
if ctx is not None and ctx.run_id:
starter = await session.scalar(select(Run.started_by).where(Run.id == ctx.run_id))
except Exception: # noqa: BLE001 - 查不了原因就照旧说
logger.exception("db_schema 查表不在的原因失败(数据源 %s)", getattr(source, "name", "?"))
return out, lines
@@ -992,7 +998,7 @@ async def _on_demand(source: Any, missing: list[str], ctx: ToolContext | None) -
out[t] = text
if take:
got = await so.add_to_scope(getattr(source, "id", ""), take, trail=so.Trail(
via=so.VIA_SCHEMA_TOOL, run_id=(ctx.run_id or None) if ctx else None,
via=so.VIA_SCHEMA_TOOL, actor=starter, run_id=(ctx.run_id or None) if ctx else None,
ref=(ctx.node_id or None) if ctx else None,
need=f"运行时 {SCHEMA_PREFIX}{getattr(source, 'name', '')} 点名:{'、'.join(take)}"))
if got.cache is not None and got.added:
+1 -1
View File
@@ -22,7 +22,7 @@ SELECT MIN(CASE WHEN paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31
-- date_window
SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' AND status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- day_volume
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY DATE(paid_at) ORDER BY d;
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '2026-10-02' GROUP BY DATE(paid_at) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
+1 -1
View File
@@ -22,7 +22,7 @@ SELECT MIN(CASE WHEN paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31
-- date_window
SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' AND status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- day_volume
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY DATE(paid_at) ORDER BY d;
SELECT DATE(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '2026-10-02' GROUP BY DATE(paid_at) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
+1 -1
View File
@@ -22,7 +22,7 @@ SELECT MIN(CASE WHEN paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31
-- date_window
SELECT * FROM (SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' AND status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status) WHERE ROWNUM <= 21;
-- day_volume
SELECT TRUNC(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY TRUNC(paid_at) ORDER BY d;
SELECT TRUNC(paid_at) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '2026-10-02' GROUP BY TRUNC(paid_at) ORDER BY d;
-- sample
SELECT * FROM (SELECT status, COUNT(*) AS cnt FROM sales.orders SAMPLE BLOCK (1) WHERE status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status) WHERE ROWNUM <= 21;
-- max_date
+1 -1
View File
@@ -22,7 +22,7 @@ SELECT MIN(CASE WHEN paid_at >= DATE '0001-01-02' AND paid_at < DATE '9999-12-31
-- date_window
SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' AND status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- day_volume
SELECT CAST(paid_at AS DATE) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '9999-12-31' GROUP BY CAST(paid_at AS DATE) ORDER BY d;
SELECT CAST(paid_at AS DATE) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= DATE '2026-08-27' AND paid_at < DATE '2026-10-02' GROUP BY CAST(paid_at AS DATE) ORDER BY d;
-- sample
SELECT status, COUNT(*) AS cnt FROM sales.orders TABLESAMPLE SYSTEM (1) WHERE status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- max_date
+1 -1
View File
@@ -22,7 +22,7 @@ SELECT MIN(CASE WHEN paid_at >= '0001-01-02' AND paid_at < '9999-12-31' THEN pai
-- date_window
SELECT status, COUNT(*) AS cnt FROM sales.orders WHERE paid_at >= '2026-08-27' AND paid_at < '9999-12-31' AND status IS NOT NULL GROUP BY status ORDER BY cnt DESC, status LIMIT 21;
-- day_volume
SELECT substr(paid_at, 1, 10) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= '2026-08-27' AND paid_at < '9999-12-31' GROUP BY substr(paid_at, 1, 10) ORDER BY d;
SELECT substr(paid_at, 1, 10) AS d, COUNT(*) AS n FROM sales.orders WHERE paid_at >= '2026-08-27' AND paid_at < '2026-10-02' GROUP BY substr(paid_at, 1, 10) ORDER BY d;
-- sample
-- (这种方言没有这类查询)
-- max_date
+64
View File
@@ -228,6 +228,70 @@ async def test_large_table_basis_window_or_first_rows_and_no_cross_basis_compare
await drop_source(b)
async def test_future_business_dates_stay_out_of_the_daily_window(client, tmp_path):
"""业务日期最晚到明天、今天只写了几行:按日行数的查询只数到今天(上界,今天以后的不算),不记量跌。以前窗口只有下界,
明天那一天也进了窗口,比较器再退一天就退到今天这个不完整的日子上,记出一条量跌(下一轮验收)。"""
path = tmp_path / "ahead.db"
_build(path, rows=0)
db = sqlite3.connect(path)
rows = [(TODAY - timedelta(days=d)).isoformat() for d in range(1, 31) for _ in range(30)]
rows += [TODAY.isoformat()] * 3 + [(TODAY + timedelta(days=1)).isoformat()] * 30
db.executemany("INSERT INTO orders (status, paid_on, amount, secret) VALUES ('paid', ?, 1, 'x')", [(r,) for r in rows])
db.commit()
db.close()
sid = await _make(client, path)
try:
job_id = await _observe(sid)
got = {(o.kind, o.column_name): o for o in await _obs(sid)}
days = got[("daily_volume", "paid_on")].data["days"]
assert (TODAY + timedelta(days=1)).isoformat() not in days and days[TODAY.isoformat()] == 3
assert got[("daily_volume", "paid_on")].data["until"] == TODAY.isoformat()
async with SessionLocal() as session:
drops = list((await session.execute(select(SourceChange).where(
SourceChange.source_id == sid, SourceChange.kind == "volume_drop"))).scalars())
assert drops == [], "今天还没写完、明天的日子不是整天:不算量跌"
assert all(o.job_id == job_id for o in got.values())
finally:
await drop_source(sid)
def test_observation_today_is_the_server_local_date(monkeypatch):
"""观察用的「今天」按服务端所在时区(和待办汇总 notify.digest_at 同一个口径):业务日期是本地日期,UTC 20 点在东八区
已经是第二天。"""
import time
from datetime import datetime, timezone
monkeypatch.setenv("TZ", "Asia/Shanghai")
time.tzset()
try:
assert observe.local_today(datetime(2026, 10, 17, 20, 0, tzinfo=timezone.utc)) == date(2026, 10, 18)
assert observe.local_today(datetime(2026, 10, 17, 15, 0, tzinfo=timezone.utc)) == date(2026, 10, 17)
finally:
monkeypatch.undo()
time.tzset()
async def test_the_profile_jobs_api_takes_the_observe_scope(client, shop):
"""取值观察以前只能经定时维护触发:POST /catalog/profile-jobs 的 scope 只收 needed、all(下一轮验收)。现在也收 observe:
只观察、不剖析;12 小时内都观察过时 409 profile_nothing,说清为什么。"""
sid, _ = shop
r = await client.post(f"/api/datasources/{sid}/catalog/profile-jobs", json={"scope": "observe"})
assert r.status_code == 202, r.text
job = r.json()["job"]
assert job["scope"] == "observe"
await catalog_profile_jobs.manager.wait(job["id"], timeout=60)
async with SessionLocal() as session:
[task] = (await session.execute(select(CatalogProfileTask).where(
CatalogProfileTask.job_id == job["id"]))).scalars()
assert {c["kind"] for c in task.checks} == set(observe.OBSERVE_KINDS), "只观察:不做关系、码值、日期的剖析"
assert {o.kind for o in await _obs(sid)} == set(observe.OBSERVE_KINDS)
again = await client.post(f"/api/datasources/{sid}/catalog/profile-jobs", json={"scope": "observe"})
assert again.status_code == 409 and again.json()["code"] == "profile_nothing"
assert f"{observe.OBSERVE_EVERY_H} 小时内都观察过" in again.json()["detail"]
bad = await client.post(f"/api/datasources/{sid}/catalog/profile-jobs", json={"scope": "maintenance"})
assert bad.status_code == 422, "maintenance 是定时维护用的,接口不收"
class _FakeCx:
"""只看拼出来的 SQL:大表、业务日期没有索引。"""
+1 -1
View File
@@ -145,7 +145,7 @@ def test_profile_sql_passes_readonly_guard_on_every_dialect(kind):
d.value_counts_sql(tbl, col, 21, scan_cap=100_000),
d.min_max_sql(tbl, [(col, False)]),
d.min_max_sql(tbl, [(col, True)]),
d.day_volume_sql(tbl, col, "2026-08-01"),
d.day_volume_sql(tbl, col, "2026-08-01", "2026-09-04"),
d.window_value_counts_sql(tbl, col, col, "2026-08-01", 21),
]
sampled = d.sample_value_counts_sql(tbl, col, 1, 21)
@@ -147,6 +147,35 @@ async def test_schema_tool_adds_an_out_of_scope_table_instead_of_denying_it(wide
assert "没有 nope" in await tool.coroutine(table="nope")
async def test_schema_tool_on_demand_trail_records_who_started_the_run(wide):
"""运行时 db_schema 按需加入范围的留痕:记上运行的发起人(by),不只是 run_id(下一轮验收:以前 by 是 null)。"""
from sqlalchemy import select as _select
from app.data import source_objects
from app.db.models import Run
from app.db.models_sources import SourceObject
from app.tools.datasource import build_datasource_tools
from app.tools.registry import ToolContext, Versions
sid, name = wide
async with SessionLocal() as session:
run = Run(workflow_name="按需加入", status="running", graph={"nodes": [], "edges": []}, input={},
started_by="分析员甲")
session.add(run)
await session.commit()
run_id = run.id
async with SessionLocal() as session:
(tool,) = await build_datasource_tools(
[f"db_schema__{name}"], ToolContext(run_id=run_id, node_id="ask", data_versions=Versions.CURRENT), session)
assert "按需加入了探查范围" in await tool.coroutine(table="t204")
async with SessionLocal() as session:
obj = (await session.execute(_select(SourceObject).where(SourceObject.source_id == sid,
SourceObject.name == "t204"))).scalar_one()
trail = source_objects.trail_dict(obj)
assert trail["via"] == source_objects.VIA_SCHEMA_TOOL and trail["run_id"] == run_id and trail["ref"] == "ask"
assert trail["by"] == "分析员甲", trail
def test_legacy_truncated_cache_still_does_not_deny():
"""升级前只探查了前 200 张的老缓存(没有对象索引):查一张没探查到的表照旧说「只探查了」、请查数据字典。"""
from types import SimpleNamespace
@@ -157,3 +186,24 @@ def test_legacy_truncated_cache_still_does_not_deny():
"truncated": True, "total": 205})
text = describe_table(row, "t300")
assert "中没有" not in text and "只探查了" in text and "information_schema" in text
async def test_catalog_detail_of_an_out_of_scope_table_says_so(client, wide):
"""对象索引里有、在探查范围外的表:GET /catalog/{表} 的 404 说「在探查范围外」,和 /schema、/catalog/search 同一套「不在」
(下一轮验收:以前说「可能已被删除或尚未探查结构」);表助手、批量审阅的同一个错误也一样。对象索引里也没有的说「没有
这张表」。"""
sid, _ = wide
r = await client.get(f"/api/datasources/{sid}/catalog/t200")
assert r.status_code == 404 and "探查范围外" in r.json()["detail"], r.text
assert "可能已被删除" not in r.json()["detail"]
found = (await client.get(f"/api/datasources/{sid}/catalog/search", params={"q": "t200"})).json()["items"]
assert [i["absent_reason"] for i in found if i["name"] == "t200"] in (["out_of_scope"], [None]), found
r = await client.post(f"/api/datasources/{sid}/catalog/t201/assistant", json={"messages": [
{"role": "user", "content": "这张表是干什么的"}]})
assert r.status_code == 404 and "探查范围外" in r.json()["detail"], r.text
r = await client.post(f"/api/datasources/{sid}/catalog/review/batch", json={"items": [
{"table_name": "t202", "path": "label", "action": "confirm", "if_version": 0}]})
[result] = r.json()["results"]
assert result["ok"] is False and "探查范围外" in result["error"], result
r = await client.get(f"/api/datasources/{sid}/catalog/no_such_table")
assert r.status_code == 404 and "没有表 no_such_table" in r.json()["detail"] and "探查范围外" not in r.json()["detail"]
+11 -5
View File
@@ -8,8 +8,9 @@
配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有
可用的表结构时说表结构不可用
- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写,
照实说原因。同一列上的时间范围不同、跨两张表的条件,差异说明只给兜底,同样不写;说明以后真把跨两张表的条件说成一条
筛选条件,derive 自己那道「只涉及一张表」的检查也拦得下
照实说原因。同一列上的时间范围不同(差异说明说成 time_range)、同一列上换了筛选的值(filter,两边都有条件)不是业务日期、
也不是一边多出来的有效记录条件,同样不写;跨两张表的条件差异说明只给兜底,说明以后真把它说成一条筛选条件,derive 自己
那道「只涉及一张表」的检查也拦得下
- 指标自己的筛选范围(只看华东、只看线上)不是有效记录条件:取值出现在指标名称、口径说明、所在分组、运行输入里的,
条件用到分组的列的,不是在排除退款、作废、测试这类无效记录的,都不写
- 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖;
@@ -234,10 +235,15 @@ def test_equivalent_sql_or_fallback_or_other_aspects_are_not_recorded():
assert [i["aspect"] for i in joined["diff"]["items"]] == ["aggregate"]
assert "不只是去不去重" in derive(joined, SCHEMA)[1]
# 同一列上的时间范围不同:差异说明只给兜底(说不清差的是哪一段),不写
# 同一列上的时间范围不同:差异说明说成 time_range(「原流程对 paid_date 的条件是 …,盲算是 …」),不是换了时间列,不写
ranged = candidate(RIGHT, RIGHT.replace("'2026-09-07'", "'2026-09-06'"), "b")
assert ranged["diff"]["fallback"] is True and [i["aspect"] for i in ranged["diff"]["items"]] == ["other"]
assert "未能归纳" in derive(ranged, SCHEMA)[1]
assert ranged["diff"]["fallback"] is False and [i["aspect"] for i in ranged["diff"]["items"]] == ["time_range"]
assert "两边差在时间范围" in derive(ranged, SCHEMA)[1]
# 同一列上换了筛选的值:两边都有条件(filter 两边都有),不是一边多出来的有效记录条件,不写
valued = candidate(RIGHT, RIGHT.replace("status <> 'refunded'", "status NOT IN ('refunded', 'void')"), "b")
assert valued["diff"]["fallback"] is False and [(i["aspect"], i["a"] is not None, i["b"] is not None)
for i in valued["diff"]["items"]] == [("filter", True, True)]
assert "两边各有不同的筛选条件" in derive(valued, SCHEMA)[1]
# 只差一个方面、可那个方面对不上目录里的三项(关联方式):不写,原因说清差在哪
left = (f"SELECT SUM(o.amount) AS gmv FROM orders o LEFT JOIN customers c ON c.id = o.customer_id "
+5 -4
View File
@@ -9,12 +9,13 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10
差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite),
两边的叫法是「原流程」「盲算」,同节点调用时一样。
**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联
四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、
**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、同一列上的条件改写、
聚合与去重、关联五类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、
只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、
差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行
条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给
兜底,审批卡默认展开逐行对照)、一边解析失败。
条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:套着 DATE() 的时间范围不同——
光一列上的范围不同如今说得出「对 paid_at 的条件是 …」,套着函数的认不出取值范围,只给兜底,审批卡默认展开逐行对照)、
一边解析失败。
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
+59 -2
View File
@@ -146,10 +146,14 @@ async def test_stop_then_retry_only_the_unfinished(monkeypatch):
await asyncio.sleep(0.1)
assert await runner.stop(run.id)
row = await get(run.id)
assert row.status == "cancelled" and row.error == runner.STOPPED
# 停止是「停下」不是「出错」:原因记在 stop_reason;没跑的题单独计 not_run,不算进 errors,done 不再等于 total
assert row.status == "cancelled" and row.stop_reason == runner.STOPPED and row.error is None
before = {r.id: (r.status, r.finished_at) for r in await results(run.id)}
done = [rid for rid, (status, _) in before.items() if status == "ok"]
assert done and any(status == "cancelled" for status, _ in before.values())
assert row.counts["errors"] == 0 and row.counts["done"] == len(done)
assert row.counts["not_run"] == 4 - len(done) and row.counts["done"] + row.counts["not_run"] == row.counts["total"]
assert row.summary["not_run"] == 4 - len(done) and row.summary["errors"] == 0
assert not await runner.stop(run.id), "已经结束的停不了"
stub(monkeypatch, delay=0.01)
@@ -175,12 +179,25 @@ async def test_a_restart_marks_unfinished_evaluations_interrupted_and_does_not_r
run_id = row.id
assert await runner.startup() >= 1
row = await get(run_id)
assert row.status == "interrupted" and row.error == runner.INTERRUPTED
assert row.status == "interrupted" and row.stop_reason == runner.INTERRUPTED and row.error is None
assert row.counts["not_run"] == 1 and row.counts["running"] == 0 and row.counts["errors"] == 0, row.counts
[item] = await results(run_id)
assert item.status == "cancelled"
assert not runner.manager.running(run_id), "不自动续跑"
async def test_stopping_a_queued_evaluation_counts_everything_as_not_run(monkeypatch):
"""排队中就停掉(还没开始):每一题都没跑,stop_reason 写停止,计数跟着重算(以前还是建的时候那份:pending 照旧)。"""
stub(monkeypatch)
suite_id = await es.make_suite("none", cases=ask_cases(2))
monkeypatch.setattr(runner.manager, "start", lambda *_a, **_k: None) # 不让它开跑:停在排队
[run] = await start(suite_id)
assert await runner.stop(run.id)
row = await get(run.id)
assert row.status == "cancelled" and row.stop_reason == runner.STOPPED
assert (row.counts["not_run"], row.counts["pending"], row.counts["done"]) == (2, 0, 0), row.counts
async def test_the_per_run_cap_stops_new_items(monkeypatch):
stub(monkeypatch, cost=1.0)
suite_id = await es.make_suite("none", cases=ask_cases(3), budget={"max_parallel": 1, "per_run_max_usd": 1.5})
@@ -190,6 +207,8 @@ async def test_the_per_run_cap_stops_new_items(monkeypatch):
items = await results(run.id)
assert [r.status for r in items] == ["ok", "ok", "skipped"]
assert row.status == "partial" and row.stop_reason == runner.LIMIT_RUN.format(limit=1.5)
assert (row.counts["done"], row.counts["not_run"], row.counts["errors"], row.counts["skipped"]) == (2, 1, 0, 0), \
"到了上限没开评的是没跑,不是跳过(跳过是评了、但期望算不出、不评分)"
assert "金额上限" in items[2].error and row.summary["cost_usd"] == 2.0
@@ -328,6 +347,44 @@ async def test_the_api_starts_estimates_and_reports_progress(client, monkeypatch
assert bad.status_code == 422 and bad.json()["code"] == "eval_variant_invalid"
async def test_results_give_the_question_actually_asked_and_the_template_apart(client, monkeypatch):
"""逐题结果的 question 是这一题实际问的(变量按这次评测的截至时间渲染好了,和作答时 Driver 收到的一字不差),模板原文
另放 question_template(下一轮验收:以前 question 是带 {{ schedule.last_week_start }} 的原始模板)。没有变量的题两个一样。"""
from datetime import datetime, timezone
from app.db.models_eval import EvalCase
from app.eval.cases import render, variable_values
asked: list[str] = []
real_init = answer_mod.Driver.__init__
def init(self, question, **kw):
asked.append(question)
real_init(self, question, **kw)
monkeypatch.setattr(answer_mod.Driver, "__init__", init)
stub(monkeypatch)
template = "{{ schedule.last_week_start }} 到 {{ schedule.last_week_end }} 那周的问题"
suite_id = await es.make_suite("none", cases=[{"question": template, "expectations": [{"kind": "ask"}]},
{"question": "没有变量的问题", "expectations": [{"kind": "ask"}]}])
as_of = datetime(2026, 10, 14, 2, 0, tzinfo=timezone.utc)
[run] = await start(suite_id, as_of=as_of)
await es.wait_eval(run.id)
rows = (await client.get(f"/api/evals/evaluations/{run.id}/results")).json()["results"]
expected = render(template, variable_values(as_of, "Asia/Shanghai", None))
assert "{{" not in expected and rows[0]["question"] == expected, rows[0]["question"]
assert rows[0]["question"] in asked, "和作答时实际问的一样"
assert rows[0]["question_template"] == template
assert rows[1]["question"] == rows[1]["question_template"] == "没有变量的问题"
# 题目改过(指纹对不上):现在的模板不一定是当时问的,没有作答运行可取时不编一个,question 为空、模板照给
async with SessionLocal() as session:
case = (await session.execute(select(EvalCase).where(EvalCase.suite_id == suite_id, EvalCase.seq == 1))).scalar_one()
case.question, case.case_hash = "改过的 {{ schedule.today }}", "changed"
await session.commit()
rows = (await client.get(f"/api/evals/evaluations/{run.id}/results")).json()["results"]
assert rows[0]["question"] is None and rows[0]["question_template"] == "改过的 {{ schedule.today }}"
async def test_eval_settings_group_and_spend_endpoint(client):
got = (await client.get("/api/settings")).json()["eval"]
assert got == {"max_parallel": 2, "per_case_timeout_s": 900, "per_run_max_usd": 2.0, "daily_max_usd": 5.0,
+29
View File
@@ -295,6 +295,35 @@ async def test_stats_count_what_the_fixtures_did(client, monkeypatch):
assert body["e4"]["per_publish"]["available"] is True and body["e4"]["per_publish"]["groups"] == 0
async def test_e4_usage_counts_formal_runs_only_of_workflows_with_a_caliber_card(client):
"""E4 的版本用量只算有口径卡的工作流(下一轮验收:没有口径卡的普通发布工作流的正式运行也被当成口径计数)。卡是转抄别处
口径(caliber_from)的不算源头口径,同 recall.source_cards。"""
from datetime import datetime, timezone
from app.db.models import Run, Workflow, WorkflowVersion
since = datetime.now(timezone.utc)
card = {"id": "card", "type": "metrics", "position": {"x": 0, "y": 0},
"data": {"label": "口径卡", "config": {"caliber": "周销售", "caliber_version": "v1", "metrics": []}}}
plain = {"id": "out", "type": "output", "position": {"x": 0, "y": 0}, "data": {"label": "出口", "config": {}}}
async with SessionLocal() as session:
made = {}
for name, nodes in (("有口径卡", [card, plain]), ("普通工作流", [plain])):
wf = Workflow(name=name, graph={"nodes": nodes, "edges": []}, status="published", published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph={"nodes": nodes, "edges": []}))
for _ in range(2):
session.add(Run(workflow_id=wf.id, version=1, run_class="formal", status="succeeded",
graph={"nodes": nodes, "edges": []}, input={}))
made[name] = wf.id
await session.commit()
body = (await client.get("/api/ledger/stats", params={"since": since.isoformat()})).json()
usage = {u["caliber"]: u for u in body["e4"]["usage"]}
assert usage[f"{made['有口径卡']}@1"]["formal_runs"] == 2
assert f"{made['普通工作流']}@1" not in usage, usage
async def test_stats_take_the_run_ids_the_caller_names(monkeypatch):
"""评测(第 3 波)直接调 compute_stats,点名自己的运行:只算这几次。"""
from app.ledger.stats import compute_stats
+52 -4
View File
@@ -180,7 +180,11 @@ async def test_freezing_makes_a_fixed_sql_draft_that_passes_the_governed_gate(cl
"required": ["m1"]}
assert {k: nodes["write"]["data"]["config"].get(k) for k in ("numbers", "on_violation", "claims")} == {
"numbers": "strict", "on_violation": "fail", "claims": "require_citation"}
assert nodes["write"]["data"]["config"]["instructions"] == f"回答:{QUESTION}"
# 有入口参数:不写代表那一轮的问题原文(「九月第一周」参数化之后就不对了),写运行时渲染的参数
instructions = nodes["write"]["data"]["config"]["instructions"]
assert QUESTION not in instructions and "2026-09" not in instructions, instructions
assert "{{ input.p1 }}" in instructions and "{{ input.p2 }}" in instructions
assert nodes["card"]["data"]["config"]["caliber"] == QUESTION, "没传名字:口径卡照旧用代表问题"
spec = GraphSpec.model_validate(wf.graph)
assert not [i for i in validate_graph(spec).issues if i.level == "error"]
assert not [i for i in publish_issues(spec, level="governed") if i.level == "error"], \
@@ -189,9 +193,12 @@ async def test_freezing_makes_a_fixed_sql_draft_that_passes_the_governed_gate(cl
assert all(s["question"] == QUESTION for s in wf.origin["sources"])
assert wf.origin["run_id"] == rep_run.id and wf.origin["freeze"] is True
# 队列上标着已经固化过
[again] = shape_items((await client.get("/api/ledger/certify-queue")).json())
assert {w["workflow_id"] for w in again["frozen_into"]} == {wf.id}
# 队列上标着已经固化过:同一个工作流只列一次(三个来源运行都指着它)
body = (await client.get("/api/ledger/certify-queue")).json()
[again] = shape_items(body)
assert [w["workflow_id"] for w in again["frozen_into"]] == [wf.id]
[cluster] = [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == QUESTION]
assert [w["workflow_id"] for w in cluster["frozen_into"]] == [wf.id], cluster["frozen_into"]
# 按受管级别发布:来源运行在后台补比,变成等价已证明、出了队
res = await client.post(f"/api/workflows/{wf.id}/publish", json={"level": "governed"})
@@ -216,6 +223,47 @@ async def test_freezing_makes_a_fixed_sql_draft_that_passes_the_governed_gate(cl
assert stats["frozen"] >= 1 and stats["published"] >= 1
async def test_freezing_uses_the_given_name_and_catalog_labels_and_no_dates_without_parameters(client, monkeypatch):
"""传了名字:口径卡用它(下一轮验收:以前用代表问题的原文)。指标名能从数据目录取到中文名的用中文名,取不到照旧用
列名。没有入口参数(字面量都没变过)时报告指令照旧写问题原文:那时问题里的日期就是口径的常量。"""
from app.data import catalog
from app.db.models import DataSource
from sqlalchemy import select
async with SessionLocal() as session:
source = (await session.execute(select(DataSource).where(DataSource.name == S.CURRENT["source"]))).scalar_one()
await catalog.write_entry(session, source.id, "orders", {"columns": {
"amount": {"label": catalog.make_item("金额", "human")}}}, if_version=0, actor="测试员")
sql = "SELECT SUM(amount) AS amount, COUNT(*) AS n FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01'"
S.script(monkeypatch, "金额 [[v:Q1.r0.amount]],[[v:Q1.r0.n]] 单。")
conv = await conversation()
async with SessionLocal() as session:
turn = ConversationTurn(conversation_id=conv, question=QUESTION)
session.add(turn)
await session.commit()
turn_id = turn.id
run = await run_manager.start(graph=S.explore_graph(sql), input_payload={"question": QUESTION}, origin_kind="chat",
origin={"conversation_id": conv, "turn_id": turn_id})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
(await session.get(ConversationTurn, turn_id)).run_id = row.id
await session.commit()
await ledger_match.drain()
res = await client.post("/api/copilot/from-run", json={"run_id": row.id, "freeze": True, "name": "九月销售额口径"})
assert res.status_code == 200, res.text
made = res.json()
assert made["params"] == [] and made["name"] == "九月销售额口径"
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
nodes = {n["id"]: n for n in wf.graph["nodes"]}
card = nodes["card"]["data"]["config"]
assert card["caliber"] == "九月销售额口径"
assert [(m["name"], m["expression"]) for m in card["metrics"]] == [
("金额", "cell(nodes.fetch, 0, 'amount')"), ("n", "cell(nodes.fetch, 0, 'n')")]
assert nodes["write"]["data"]["config"]["instructions"] == f"回答:{QUESTION}"
async def test_freezing_needs_a_matched_successful_run(client, monkeypatch):
res = await client.post("/api/copilot/from-run", json={"run_id": "nope", "freeze": True})
assert res.status_code == 404
+49 -3
View File
@@ -464,11 +464,57 @@ async def test_recall_notification_has_no_numbers(monkeypatch):
text = mine[0]["title"] + "".join(f"{k}{v}" for k, v in mine[0]["lines"])
for number in ("170.5", "208.5", "38.0", "38.00"):
assert number not in text, f"消息里不放数:{number}"
assert dict(mine[0]["lines"]) == {"状态": "待处理", "口径": "「周销售」", "版本": "v1 → v2", "答案": "1 个",
assert dict(mine[0]["lines"]) == {"状态": "待处理", "口径": "「周销售」", "版本": "v1 → v2", "答案": "问数回答 1 个",
"召回": dict(mine[0]["lines"])["召回"]}
assert "周销售" in mine[0]["title"] and "v2" in mine[0]["title"]
async def test_recall_message_counts_answers_by_kind(monkeypatch):
"""召回消息按种类分开写条数:一个问数回答、一次正式运行写「问数回答 1 个、正式运行 1 个」(下一轮验收:以前写「答案:2 个」,
读的人以为是两个问数回答)。只写条数,照旧不放数。"""
from app.core.crypto import encrypt
from app.db.models import NotifyChannel
from app.engine.launch import launch_run
from app.notify import service
sent: list[dict] = []
async def capture(channel, message):
sent.append({"channel": channel.name, **message})
from app.notify.core import Outcome
return Outcome(True, 200, 1, None)
monkeypatch.setattr("app.notify.send", capture)
async with SessionLocal() as session:
row = NotifyChannel(name="召回-按种类", scope="global", url=encrypt("https://hooks.example.com/recall-kinds"),
events=["ledger.recall"])
session.add(row)
await session.commit()
channel_id = row.id
try:
wf = await S.publish()
await answer(monkeypatch)
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
async with SessionLocal() as session:
formal = await launch_run(session, workflow_id=wf, run_class="formal", version=1, input_payload=WEEK1,
actor="analyst")
assert (await S.wait(formal.id)).status == "succeeded"
await publish_v2(wf)
await run_recall(wf)
await service.drain()
finally:
async with SessionLocal() as session:
await session.delete(await session.get(NotifyChannel, channel_id))
await session.commit()
mine = [m for m in sent if m["channel"] == "召回-按种类"]
assert mine, sent
answers = [v for m in mine for k, v in m["lines"] if k == "答案"]
assert "问数回答 1 个、正式运行 1 个" in answers, answers
for number in ("170.5", "208.5", "38.0"):
assert all(number not in v for m in mine for _, v in m["lines"]), "消息里不放数"
async def test_recall_pushes_the_shared_channel_once_per_recall_run(monkeypatch):
"""开登录时一组答案有几个主人就开几条待办,但全局渠道(团队群)按召回运行只收一条(答案、待办都按这次召回汇总,链到
待办箱);订阅了 task.opened 的全局渠道也不再按每条待办各推一次。每个主人自己的个人渠道各收他那一条。"""
@@ -522,12 +568,12 @@ async def test_recall_pushes_the_shared_channel_once_per_recall_run(monkeypatch)
team = [m for m in sent if m["channel"] == "团队-召回"]
assert [m["event"] for m in team] == ["ledger.recall"], [m["event"] for m in team]
lines = dict(team[0]["lines"])
assert lines["答案"] == "2 个" and lines["待办"] == "2 条", lines
assert lines["答案"] == "问数回答 2 个" and lines["待办"] == "2 条", lines
assert [label for label, _ in team[0]["links"]] == ["去待办箱"] or team[0]["links"] == []
assert [m["event"] for m in sent if m["channel"] == "团队-待办"] == ["task.opened"], "只订阅了新待办的团队渠道也只收一条"
for name in ("analyst_a", "analyst_b"):
mine = [m for m in sent if m["channel"] == f"个人-{name}"]
assert [m["event"] for m in mine] == ["ledger.recall"] and dict(mine[0]["lines"])["答案"] == "1 个", mine
assert [m["event"] for m in mine] == ["ledger.recall"] and dict(mine[0]["lines"])["答案"] == "问数回答 1 个", mine
# 账本接口里的召回待办只列看的这个人手上的:第一个答案的主人看得到自己那条,另一个主人看不到
from app.api.deps import Principal
+1 -1
View File
@@ -69,7 +69,7 @@ def _queries(kind: str) -> list[tuple[str, str | None]]:
(d.column(META["orders"], "biz_day"), True)])),
("date_window", d.window_value_counts_sql(orders, status, d.column(META["orders"], "paid_at"),
"2026-08-27", 21)),
("day_volume", d.day_volume_sql(orders, d.column(META["orders"], "paid_at"), "2026-08-27")),
("day_volume", d.day_volume_sql(orders, d.column(META["orders"], "paid_at"), "2026-08-27", "2026-10-01")),
("sample", d.sample_value_counts_sql(orders, status, 1, 21)),
# 第 3 波:取值观察的最新业务日期(文字存的日期按字符串比)
("max_date", d.max_date_sql(orders, d.column(META["orders"], "paid_at"))),
+29
View File
@@ -186,6 +186,35 @@ async def test_the_api_carries_end_reason_and_refuses_to_continue_a_rejected_run
assert stop.status_code == 409 and "被驳回" in stop.json()["detail"]
async def test_resume_on_a_rejected_run_says_rejected_with_the_same_code(client):
"""对被驳回的运行调 /resume:照 end_reason 说「被驳回」,和 /continue 同一个错误码 run_rejected(下一轮验收:以前说
「这次运行已取消…已取消的运行请重新发起」)。手动停止的照旧说已取消;存量的 failed + 驳回也说被驳回,不再叫人点
「继续运行」。"""
run_id = await _rejected_run("approve")
await _wait(run_id, ("cancelled",))
r = await client.post(f"/api/runs/{run_id}/resume", json={"response": {"approved": True}})
assert r.status_code == 409 and r.json()["code"] == "run_rejected", r.text
assert "被驳回" in r.json()["detail"] and "已取消" not in r.json()["detail"]
async with SessionLocal() as session:
stopped = Run(workflow_name="停掉的", status="cancelled", end_reason="stopped", graph=chain(START, OUT), input={})
old = Run(workflow_name="老的驳回", status="failed", graph=chain(START, GATES["approve"][0], OUT), input={},
error="人工驳回:不对", error_node_id="gate")
session.add_all([stopped, old])
await session.flush()
session.add_all([
RunEvent(run_id=old.id, seq=1, type="node.failed", node_id="gate", ts=1.0, data={"error": "人工驳回:不对"}),
Task(run_id=old.id, node_id="gate", kind="approve", title="可以吗", status="resolved",
outcome={"decision": "reject"}),
])
await session.commit()
stopped_id, old_id = stopped.id, old.id
other = await client.post(f"/api/runs/{stopped_id}/resume", json={"response": {"approved": True}})
assert other.status_code == 409 and other.json().get("code") != "run_rejected" and "已取消" in other.json()["detail"]
legacy = await client.post(f"/api/runs/{old_id}/resume", json={"response": {"approved": True}})
assert legacy.status_code == 409 and legacy.json()["code"] == "run_rejected", legacy.text
assert "继续运行" not in legacy.json()["detail"].replace("不能继续运行", "")
async def test_a_legacy_failed_rejection_cannot_be_continued_either(client):
"""加 end_reason 之前被驳回的运行记的是 failed(封存不改写):失败的那一步上最近一条处理完的待办是驳回,也不许接着跑。
别的失败照旧能接着跑(这里断点不在,说的是没有断点,不是被驳回)。"""
+4
View File
@@ -326,6 +326,10 @@ async def test_revoked_and_expired_links_say_so_with_410(world): # noqa: F811
admin = await auth_env.login("root")
r = await admin.delete(f"/api/apps/{app_id}/shares/{gone['id']}")
assert r.status_code == 200 and r.json()["state"] == "revoked" and r.json()["revoked_by"] == "root"
# 撤销交回的那一条和建、列的同一个形状:page_title 是页面标题,不是页面 id(下一轮验收:以前撤销回的是「home」)
assert gone["page_title"] == "首页" and r.json()["page_title"] == gone["page_title"], r.json()["page_title"]
again = await admin.delete(f"/api/apps/{app_id}/shares/{gone['id']}")
assert again.json()["page_title"] == "首页", "撤销过的再撤销照旧交回它,标题一样"
async with SessionLocal() as s:
(await s.get(ViewShare, old["id"])).expires_at = utcnow() - timedelta(minutes=5)
await s.commit()
+26
View File
@@ -111,6 +111,32 @@ async def test_fire_builds_introspect_then_profile_in_order(client, tmp_path, ma
await drop_source(sid)
async def test_observing_again_within_the_window_records_why_in_the_fire(client, tmp_path, made):
"""只选取值观察:12 小时内观察过的表不再观察,任务以 0 张表结束。建任务时就判断得出(这次没有探查、也没排在起草后面,
开始时表结构、目录不会变):原因记进触发记录的 inputs.skipped 和 reason(下一轮验收:以前只在剖析任务的提醒里)。
任务照建(触发照旧是 started,不当成没能发起)。"""
sid = await _source(client, tmp_path / "c.db")
try:
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
row.options = sm.with_steps(row.options, ["observe"], actor="测试员")
await session.commit()
schedule_id = await _schedule(sid)
made.append(schedule_id)
at = utcnow().replace(microsecond=0)
first = await service.fire_once(schedule_id, at, "manual")
assert first.status == "started" and not first.inputs.get("skipped"), first.inputs
await _wait_jobs(first)
second = await service.fire_once(schedule_id, at + timedelta(seconds=1), "manual")
await _wait_jobs(second)
assert second.status == "started" and [j["tables"] for j in second.inputs["jobs"]] == [0]
[skipped] = second.inputs["skipped"]
assert skipped["step"] == "observe" and "12 小时内都观察过" in skipped["reason"], skipped
assert "取值观察没有执行" in second.reason
finally:
await drop_source(sid)
async def test_profile_off_records_steps_not_run_and_still_introspects(client, tmp_path, made):
sid = await _source(client, tmp_path / "b.db", profile=False)
try:
+97 -9
View File
@@ -616,10 +616,10 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected):
@pytest.mark.parametrize("a,b", [
# 同一列上的时间范围不同、同一列上换了筛选的值:不在能说的四类里
(BASE, "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'"),
("SELECT SUM(amount) FROM orders WHERE status = 'paid'",
"SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')"),
# 算出来的值上换了筛选的值:认不出取值范围,不在能说的五类里(光一列上的条件改写见
# test_rewritten_conditions_on_one_column_say_both_sides)
("SELECT SUM(amount) FROM orders WHERE LOWER(status) = 'paid'",
"SELECT SUM(amount) FROM orders WHERE LOWER(status) IN ('paid', 'shipped')"),
# 聚合的参数不同、选出来的列换了先后
("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders"),
("SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id",
@@ -653,8 +653,8 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected):
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) SELECT COUNT(*) FROM t WHERE total > 100"),
], ids=lambda v: None)
def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b):
"""具体说法只说四类(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联类型),而且只在
两边都是简单形状时说。别的不同(同一列上的范围、换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、
"""具体说法只说五类(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和去重、关联的表和
关联类型),而且只在两边都是简单形状时说。别的不同(算出来的值上换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、
UNION、CTE 和子查询里的不同)以前说得出具体的,复验在这些地方一再抓到不存在的差异(被 WHERE 定死的排序键、恒为真的
HAVING、外层不在乎的子查询差异……),现在一律只给兜底说明,附规范化后不同的行。"""
for dialect in DIALECTS:
@@ -663,6 +663,94 @@ def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b):
assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], (dialect, diff.items)
@pytest.mark.parametrize("a,b,aspect,text", [
# 等号对 IN:口径 status = 'paid',这次多收了 refunded(账本的叫法:A 是这次、B 是口径)
("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'refunded')",
"SELECT SUM(amount) FROM orders WHERE status = 'paid'", "filter",
"这次对 status 的条件是 status IN ('paid', 'refunded'),口径是 status = 'paid'(这次多了 'refunded')"),
# 取值集合不同:各多了几个
("SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status IN ('paid', 'open', 'done')",
"SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status IN ('paid', 'void')", "filter",
"这次对 orders.status 的条件是 orders.status IN ('done', 'open', 'paid'),口径是 orders.status IN ('paid', 'void')"
"(这次多了 'done'、'open',口径多了 'void')"),
# 取值毫不相干:前一句已经说全了,不补
("SELECT COUNT(*) FROM orders WHERE status = 'paid'", "SELECT COUNT(*) FROM orders WHERE status = 'done'", "filter",
"这次对 status 的条件是 status = 'paid',口径是 status = 'done'"),
# 范围的端点不同
("SELECT COUNT(*) FROM orders WHERE amount >= 100", "SELECT COUNT(*) FROM orders WHERE amount > 100", "filter",
"这次对 amount 的条件是 amount >= 100,口径是 amount > 100"),
# 同一时间列上的范围不同:time_range
(BASE, "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-11-01'", "time_range",
"这次对 paid_at 的条件是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01',"
"口径是 paid_at >= '2026-09-01' AND paid_at < '2026-11-01'"),
], ids=lambda v: None)
def test_rewritten_conditions_on_one_column_say_both_sides(a, b, aspect, text):
"""同一列上的条件两边各写了一套、留下的取值不同(下一轮验收:口径 status = 'paid',这次 status IN ('paid',
'refunded') 以前只给兜底):说「这次对 X 的条件是 …,口径是 …」,aspect 两边都有(dual_check_facts 不会把它当成一边
多出来的有效行条件)。各方言、给不给表结构都一样。"""
for dialect in DIALECTS:
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(a, b, dialect, schema=schema, labels=("这次", "口径"))
check_shape(diff)
assert not diff.equivalent and not diff.fallback, (dialect, diff.items)
[item] = diff.items
assert (item.aspect, item.text) == (aspect, text), (dialect, item)
assert item.a is not None and item.b is not None
assert item.detail == {"columns": [{"table": "orders", "column": text.split("对 ")[1].split(" ")[0]
.split(".")[-1]}]}
@pytest.mark.parametrize("a,b,schema", [
# 冗余的写法:算出来留下的取值一样,不说
("SELECT COUNT(*) FROM orders WHERE amount >= 100 AND amount >= 50", "SELECT COUNT(*) FROM orders WHERE amount >= 100",
None),
# 主键不为空:差的只是空值那一段,不说(没给表结构时照说,那时不知道它不为空)
("SELECT COUNT(*) FROM orders WHERE id = 1 OR id IS NULL", "SELECT COUNT(*) FROM orders WHERE id = 1", "schema"),
# WHERE 之外还用到了这一列:SUM(CASE WHEN status = 'paid' …) 配上 IN (…) 和只筛 'paid' 结果一样
("SELECT SUM(CASE WHEN status = 'paid' THEN amount END) FROM orders WHERE status IN ('paid', 'refunded')",
"SELECT SUM(amount) FROM orders WHERE status = 'paid'", None),
# 和别的列交错的条件
("SELECT COUNT(*) FROM orders WHERE status = 'paid' OR amount > 100",
"SELECT COUNT(*) FROM orders WHERE status IN ('paid', 'done') OR amount > 100", None),
], ids=lambda v: None)
def test_rewritten_conditions_that_may_not_change_the_result_are_not_said(a, b, schema):
for dialect in DIALECTS:
diff = explain_sql_diff(a, b, dialect, schema=sql_pairs.schema_cache() if schema else None)
check_shape(diff)
assert not diff.equivalent and not any("的条件是" in d.text for d in diff.items), (dialect, diff.items)
def test_explaining_rewritten_conditions_never_changes_the_equivalence_verdict(monkeypatch):
"""同一列上的条件改写只进说明:等价与否、在哪一级规范形式上判的,和不说这一类时一字不差(语料各对、两种先后、给不给
表结构);规范化本来就认得的改写(IN 只有一个值、IN 列表去重排序)照样判等价、没有条目。"""
from app.data import sql_semantics
pairs = [(p.a, p.b) for p in sql_pairs.PAIRS] + [
("SELECT SUM(amount) FROM orders WHERE status IN ('paid')", "SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
("SELECT COUNT(*) FROM orders WHERE status IN ('void', 'paid', 'paid')",
"SELECT COUNT(*) FROM orders WHERE status IN ('paid', 'void')"),
("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'refunded')",
"SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
]
schema = sql_pairs.schema_cache()
def verdicts() -> list[tuple[bool, str | None]]:
out = []
for a, b in pairs:
for x, y in ((a, b), (b, a)):
for sc in (None, schema):
diff = explain_sql_diff(x, y, "sqlite", schema=sc)
out.append((diff.equivalent, diff.level))
return out
with_rewrites = verdicts()
monkeypatch.setattr(sql_semantics._Explainer, "_rewrites", lambda self, A, B, xa, xb, oa, ob, m: (oa, ob))
assert verdicts() == with_rewrites
for a, b in pairs[-3:-1]:
diff = explain_sql_diff(a, b, "sqlite")
assert diff.equivalent and diff.items == [], (a, b)
def test_a_difference_inside_a_cte_used_only_in_a_condition_subquery_is_never_missed():
"""第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在条件里有子查询的不是
简单形状,不说具体的,但一定不判等价、给兜底说明和规范化后不同的行。EXISTS、标量子查询一样。"""
@@ -754,9 +842,9 @@ def test_nothing_specific_gives_one_fallback_item_and_the_lines_that_differ():
def test_specific_items_that_cover_only_part_end_with_the_rest_note():
"""按哪一列筛时间说得准,同一列 status 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。"""
a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND status = 'paid'"
b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND status IN ('paid', 'done')"
"""按哪一列筛时间说得准,LOWER(status) 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。"""
a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND LOWER(status) = 'paid'"
b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND LOWER(status) IN ('paid', 'done')"
diff = explain_sql_diff(a, b, labels=LABELS)
check_shape(diff)
assert diff.fallback and [d.text for d in diff.items] == ["原流程按 paid_at 筛时间,盲算按 created_at", FALLBACK_REST]
+2 -2
View File
@@ -73,11 +73,11 @@ def test_an_extra_condition_sorted_first_does_not_drag_the_shared_ones_along():
def test_two_conditions_on_each_side_mark_only_the_one_that_differs():
"""同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。"""
"""同一列上的区间只差结束日期(差异说明说成 time_range,逐行对照照样给):开始日期那一行两边一样,不标。"""
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'"
got = diff(a, b)
assert got.fallback
assert not got.fallback and [i.aspect for i in got.items] == ["time_range"]
assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'")
+79
View File
@@ -105,6 +105,85 @@ async def test_metering_counts_by_model_and_prices_from_the_catalog():
assert got["cost_usd"] == pytest.approx(estimate_cost(PRICED, 2000, 400))
class Bare(Scripted):
"""不报调用参数的模型(同以前的演示模型):invocation_params 里没有 model,只在对象上有。"""
@property
def _identifying_params(self) -> dict[str, Any]:
return {}
async def test_the_model_name_comes_from_the_model_object_when_the_call_parameters_lack_it():
"""评测汇总里助手用量的模型以前记成 unknown(下一轮验收):演示模型的调用参数里没有模型名,计量也就认不出。现在
调用参数里没有时看 LangChain 照模型对象给回调的 ls_model_name;演示模型自己也把 model_name 报进调用参数。"""
from app.providers.mock_model import MockChatModel
m = usage.meter()
with usage.metering(m):
await Bare(model=PRICED + "-bare").ainvoke("你好")
await MockChatModel(model_name="mock-wide", latency=0).ainvoke("你好")
got = m.summary()["by_model"]
assert set(got) == {PRICED + "-bare", "mock-wide"}, got
assert usage.UNKNOWN_MODEL not in got
@pytest.mark.parametrize("build", ["openai", "anthropic", "mock"])
def test_the_call_parameters_of_each_provider_kind_carry_the_configured_model(build):
"""三种接入(OpenAI 兼容、Anthropic、演示模型)由 providers/factory 建出来的模型:LangChain 回调时给的调用参数和
ls_model_name(这里照它的写法直接取,不发请求)计量都认得出,记的就是接入里配的模型 id。"""
import uuid as _uuid
from types import SimpleNamespace
from langchain_core.outputs import LLMResult
from app.providers.factory import ModelSpec, build_chat_model
provider = SimpleNamespace(kind=build, name="虚构接入", api_key=None, base_url=None, extra={},
default_model=None)
model_id = {"openai": "gpt-made-up", "anthropic": "claude-made-up", "mock": "mock-wide"}[build]
import os
env = {"openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY"}.get(build)
old = os.environ.get(env) if env else None
if env:
os.environ[env] = "sk-made-up"
try:
model = build_chat_model(provider, ModelSpec(model=model_id))
finally:
if env:
if old is None:
os.environ.pop(env, None)
else:
os.environ[env] = old
m = usage.meter()
run_id = _uuid.uuid4()
m.on_chat_model_start({}, [], run_id=run_id, invocation_params=model._get_invocation_params(),
metadata=dict(model._get_ls_params()))
m.on_llm_end(LLMResult(generations=[[ChatGeneration(message=AIMessage(content="好"))]]), run_id=run_id)
assert list(m.summary()["by_model"]) == [model_id]
# 调用参数里本身就带着(不靠 ls_model_name 兜着)
params = model._get_invocation_params()
assert model_id in (params.get("model"), params.get("model_name")), params
async def test_the_eval_driver_on_the_demo_model_records_its_name(monkeypatch):
"""评测作答走演示模型时,助手用量记在演示模型的名下,不是 unknown(下一轮验收:写作用量的模型对、助手的记成 unknown)。"""
import app.api.copilot as copilot
from app.eval import answer
from app.providers.mock_model import MockChatModel
async def _model(*_a, **_k):
return MockChatModel(model_name="mock-fast", latency=0), "mock-fast"
monkeypatch.setattr(copilot, "get_chat_model", _model)
driver = answer.Driver("按天看销售额", datasource_ids=[], ctx=answer.TurnContext(
suite_id="s", suite_name="虚构题集", eval_run_id="e", case_id=None, case_seq=1, actor=None))
ops, refused = await driver._generate("按天看销售额", None)
assert refused is None
driver._take(ops)
got = driver.turn.usage["assistant"]["by_model"]
assert set(got) == {"mock-fast"} and got["mock-fast"]["calls"] >= 1, got
async def test_outside_the_meter_nothing_is_counted():
m = usage.meter()
with usage.metering(m):
+27
View File
@@ -98,6 +98,33 @@ def test_volume_drop_against_the_previous_28_days():
assert vd.daily(partial, [], today=TODAY) == []
def test_future_business_dates_do_not_turn_today_into_the_last_full_day():
"""业务日期有未来值(最晚到明天):最近一个整天仍是昨天,今天这个还没写完的日子不算量跌;昨天真跌了照样报。以前取
max(days) 再退一天,max 在明天时就落到了今天(只有几行)上,记出一条量跌。"""
start = TODAY - timedelta(days=30) # start + 30 是今天
ahead = _days(start, [100] * 30 + [5, 40]) # 昨天 100、今天 5 行、明天 40 行
assert not [f for f in vd.daily(ahead, [], today=TODAY) if f.kind == "volume_drop"]
[f] = [f for f in vd.daily(_days(start, [100] * 29 + [20, 5, 40]), [], today=TODAY) if f.kind == "volume_drop"]
assert f.after["day"] == (TODAY - timedelta(days=1)).isoformat() and f.after["rows"] == 20
def test_days_after_today_are_not_gaps_now_or_in_history():
"""缺天只看到今天为止:今天以后两天没数据、第三天又有(预约、计划类的日期)不算缺天;以前那次观察窗口里它自己的「今天」
以后的空日子也不算以前有过缺天,挡不住这一次真缺的天。"""
start = TODAY - timedelta(days=30)
full = _days(start - timedelta(days=1), [100] * 31)
ahead = _days(start, [100] * 31 + [0, 0, 3]) # 今天之后空两天、第三天 3 行
assert not [f for f in vd.daily(ahead, [full], today=TODAY) if f.kind == "date_gap"]
then = TODAY - timedelta(days=7)
old = vd.Obs("daily_volume", "window:paid_on:35", {
"until": then.isoformat(),
"days": {**{(then - timedelta(days=i)).isoformat(): 100 for i in range(30)},
(then + timedelta(days=5)).isoformat(): 2}}, AT) # 那次的「今天」以后空了几天、又有一行
holed = _days(start, [100] * 10 + [0, 0] + [100] * 19)
[gap] = [f for f in vd.daily(holed, [old], today=TODAY) if f.kind == "date_gap"]
assert gap.after["gaps"] == 2
def _rows(n: int, method: str = "count") -> vd.Obs:
return vd.Obs("rows", method, {"rows": n, "method": method}, AT)
+1 -1
View File
@@ -530,7 +530,7 @@ export const api = {
* 409 带机读码:profile_disabled、datasource_inactive、schema_missing、snapshot_tampered、profile_busy(这个源已有排队、
* 进行中的剖析任务,detail 写明进行到第几张)、profile_nothing(按范围挑出 0 张表,detail 写明原因和建议)
*/
create: (sourceId: string, body: { tables?: string[]; scope?: 'needed' | 'all' }) =>
create: (sourceId: string, body: { tables?: string[]; scope?: 'needed' | 'all' | 'observe' }) =>
post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs`, body),
/** 排队或进行中的那个剖析任务;没有为 null */
active: (sourceId: string, opts?: RequestOptions) =>
+4 -4
View File
@@ -843,8 +843,8 @@
},
"fallback": {
"sql": [
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'"
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND DATE(paid_at) < '2026-09-08'",
"SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND DATE(paid_at) < '2026-09-09'"
],
"diff": {
"equivalent": false,
@@ -857,8 +857,8 @@
"fallback": true,
"text_diff": [
" WHERE",
"- AND orders.paid_at < '2026-09-08'",
"+ AND orders.paid_at < '2026-09-09'"
"- AND DATE(orders.paid_at) < '2026-09-08'",
"+ AND DATE(orders.paid_at) < '2026-09-09'"
]
},
"catalog": {
+7
View File
@@ -3035,6 +3035,11 @@ export const PROFILE_TEXT = {
scopeNeededNone: '没有需要剖析的表:都剖析过、上次都做完了,也没有新的待核实关联关系和新的码值、日期列',
scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`,
scopeAllHint: '有可剖析项的表,不含视图',
/** 取值观察(范围 observe):只观察、不剖析。张数是有数据目录的表,其中最近观察过的服务端这次不观察 */
scopeObserve: (n: number) => `取值观察:有数据目录的 ${formatNumber(n)} 张表`,
scopeObserveHint: (hours: number) => '只观察、不剖析:码值分布、最新业务日期、按日行数和行数,和同口径的历史比出取值变化,'
+ `列在「变化」里。最近 ${formatNumber(hours)} 小时内观察过的表这次不再观察`,
scopeObserveNone: '还没有有数据目录的表:取值观察只看有数据目录的表,请先起草数据目录',
/** 已选、筛选结果里有剖析不了的表:不交给服务端,写明有几张 */
scopeLeftOut: (n: number) => `另有 ${formatNumber(n)} 张不剖析(视图、表结构中已没有,或没有可剖析的项)`,
scopeEmpty: '这个范围里没有可剖析的表',
@@ -3230,6 +3235,7 @@ export const PROFILE_TEXT = {
busy: '这个数据源已有剖析任务',
tampered: '数据文件核对未通过',
nothing: '没有可剖析的表',
nothingObserve: '没有要观察的表',
other: '无法开始剖析',
} as Record<string, string>,
blockedNext: {
@@ -3239,6 +3245,7 @@ export const PROFILE_TEXT = {
busy: '同一个数据源同一时间只有一个剖析任务:可以查看它的进度,等它结束后再开始新的剖析。',
tampered: '请重新上传表格,或联系管理员核对数据文件。',
nothing: '可以改选「全部表」,或在表清单中选定要剖析的表。',
nothingObserve: '观察过的表要过一段时间才再观察;要剖析,可以回到上一步改选「需要剖析的表」或「全部表」。',
other: '请稍后重试。',
} as Record<string, string>,
busyAt: (who: string | null, done: number, total: number) =>
+4 -1
View File
@@ -411,7 +411,8 @@ export const EVAL_TEXT = {
traced: '可追溯准确率',
tracedHint: '必需的期望全部经引用对上(答案里的数点得开、来自查询的那一格或口径卡的指标)',
verdicts: '结论',
notRun: (n: number) => `${formatNumber(n)} 项没评(停下、出错、到了上限)`,
notRun: (n: number) => `${formatNumber(n)} 项没评(停止、中断,或者到了上限、止损之后没开评)`,
errors: (n: number) => `${formatNumber(n)} 项出错`,
cost: '花费',
costHint: '助手(建图、挑表、复核)+ 写作(答案运行里的模型调用)+ 结论裁判;算期望不调模型',
perCase: (v: string) => `每题约 ${v}`,
@@ -473,6 +474,8 @@ export const EVAL_TEXT = {
cleaned: '明细已清理',
seq: '题',
question: '问法',
/** 逐题表里实际问的问题下面一行:题目原文和实际问的不同(带变量的模板,或者题目之后改过)时写原文 */
questionTemplate: (text: string) => `题目原文:${text}`,
verdict: '结论',
expected: '期望',
got: '答案里的数',
+25 -14
View File
@@ -20,8 +20,8 @@ import {
} from '../../lib/terms'
import { STATUS_TONE, StatusChip } from './parts'
import {
PROFILE_MAX_TABLES, PROFILE_RETRYABLE, UNFINISHED_REASONS, coverageText, fillKey, isProfilable, profileBlockOf, profileScopeTables,
secondsText, sizeText, spanText,
PROFILE_MAX_TABLES, PROFILE_OBSERVE_EVERY_H, PROFILE_RETRYABLE, UNFINISHED_REASONS, coverageText, fillKey, isProfilable, profileBlockOf,
profileScopeTables, secondsText, sizeText, spanText,
} from './profile'
import type { ProfileBlock } from './profile'
import { isJobActive } from './draftJob'
@@ -32,16 +32,18 @@ import type { ProfileStartBody } from './profileJob'
// 数据目录页的「数据剖析」。剖析是服务端的后台任务(profileJob.ts):这里选范围、看预算、建任务;之后显示页面跟着的
// 那个任务(进行中的逐表进度、结束后的结果),任务本身的状态在页面上,关掉对话框照常进行。照起草对话框的结构:
//
// 开始前:范围(已选 / 当前筛选 / 需要剖析的表(默认)/ 全部表,个数按清单上每行的 profile_due、profile_checks 算)、按表写的
// 预算和这次最多约几条查询、醒目一句「只读查询、一条一条发、可随时停止」;同一个数据源有起草任务时按钮写「排队剖析」。
// 开始前:范围(已选 / 当前筛选 / 需要剖析的表(默认)/ 全部表 / 取值观察,个数按清单上每行的 profile_due、profile_checks、
// 有没有数据目录算;取值观察只观察不剖析,最近观察过的服务端这次不观察,张数是上限)、按表写的预算和这次最多约几条查询、
// 醒目一句「只读查询、一条一条发、可随时停止」;同一个数据源有起草任务时按钮写「排队剖析」。
// 进行中:几张 / 共几张、已发几条查询、用时,按表数的真进度条,停止;逐表一行(状态、各类检查的进度、查询数、用时、正在做
// 哪一项),点一行展开这张表的检查清单、发现和发过的查询。
// 结束后:汇总(各状态几张、核实了什么)、止损和停止的原因照实写、按「有发现 / 有跳过 / 失败」筛、重试没做完的表;码值的
// 「填写含义」照旧(填完回到这里,接着下一列)。
// 开始不了(409):每种情况一句说法和下一步(未开启就地打开剖析设置;正忙写清是谁的、到哪了,可以去看它的进度)。
// 开始不了(409):每种情况一句说法和下一步(未开启就地打开剖析设置;正忙写清是谁的、到哪了,可以去看它的进度;取值观察
// 挑不出表时说「没有要观察的表」,不给「改选全部表」)。
// ===========================================================================
type Scope = 'selected' | 'filtered' | 'needed' | 'all'
type Scope = 'selected' | 'filtered' | 'needed' | 'all' | 'observe'
type Filter = 'all' | 'findings' | 'skipped' | 'failed'
const FILTERS: readonly Filter[] = ['all', 'findings', 'skipped', 'failed']
@@ -114,6 +116,10 @@ export function ProfileDialog({
tables: needed, leftOut: 0, body: { scope: 'needed' } })
const all = profileScopeTables(rows, 'all')
out.push({ key: 'all', label: PT.scopeAll(all.length), hint: PT.scopeAllHint, tables: all, leftOut: 0, body: { scope: 'all' } })
const watched = profileScopeTables(rows, 'observe')
out.push({ key: 'observe', label: PT.scopeObserve(watched.length),
hint: watched.length ? PT.scopeObserveHint(PROFILE_OBSERVE_EVERY_H) : PT.scopeObserveNone, tables: watched, leftOut: 0,
body: { scope: 'observe' } })
return out
}, [rows, visible, selected, filtered])
const [scope, setScope] = useState<Scope>(() => {
@@ -260,8 +266,9 @@ export function ProfileDialog({
<div className="space-y-1.5" role="radiogroup" aria-label={PT.scope}>
{scopes.map((s) => {
const over = !!s.body.tables && s.tables.length > PROFILE_MAX_TABLES
const note = over ? PT.scopeTooMany(PROFILE_MAX_TABLES) : !s.tables.length && s.key !== 'needed' ? PT.scopeEmpty
: s.leftOut ? PT.scopeLeftOut(s.leftOut) : s.hint
const note = over ? PT.scopeTooMany(PROFILE_MAX_TABLES)
: !s.tables.length && s.key !== 'needed' && s.key !== 'observe' ? PT.scopeEmpty
: s.leftOut ? PT.scopeLeftOut(s.leftOut) : s.hint
return (
<label key={s.key} className={clsx('flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-xs',
picked.key === s.key ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')} data-profile-scope={s.key}
@@ -307,7 +314,8 @@ export function ProfileDialog({
</>
)
} else if (phase === 'blocked' && blocked) {
body = <Blocked blocked={blocked} onSettings={onSettings} onGoSource={onGoSource} onViewActive={() => void viewActive()}
body = <Blocked blocked={blocked} observing={blocked.body.scope === 'observe'} onSettings={onSettings} onGoSource={onGoSource}
onViewActive={() => void viewActive()}
onChooseAll={() => { setScope('all'); void begin({ scope: 'all' }) }} onRetry={() => void begin(blocked.body)} />
footer = (
<>
@@ -384,32 +392,35 @@ function Budget({ settings: s, sourceName, tables, onSettings }: {
}
/** 开始不了(409):是哪种情况、服务端的原话、下一步和对应的按钮 */
function Blocked({ blocked: b, onSettings, onGoSource, onViewActive, onChooseAll, onRetry }: {
function Blocked({ blocked: b, observing, onSettings, onGoSource, onViewActive, onChooseAll, onRetry }: {
blocked: { kind: ProfileBlock; message: string; active: CatalogProfileJob | null }
/** 选的是取值观察:挑不出表时说「没有要观察的表」,不给「改选全部表」(那是剖析,不是观察) */
observing: boolean
onSettings: () => void
onGoSource: () => void
onViewActive: () => void
onChooseAll: () => void
onRetry: () => void
}) {
const nothingObserve = observing && b.kind === 'nothing'
const action = b.kind === 'disabled'
? <button type="button" className="btn btn-sm btn-primary" onClick={onSettings} data-profile-enable="">{PT.enable}</button>
: b.kind === 'busy'
? <button type="button" className="btn btn-sm btn-primary" onClick={onViewActive} data-profile-view-active="">{PT.viewActive}</button>
: b.kind === 'nothing'
? <button type="button" className="btn btn-sm" onClick={onChooseAll} data-profile-choose-all="">{PT.chooseAll}</button>
? (observing ? null : <button type="button" className="btn btn-sm" onClick={onChooseAll} data-profile-choose-all="">{PT.chooseAll}</button>)
: b.kind === 'other'
? <button type="button" className="btn btn-sm" onClick={onRetry} data-profile-retry-start="">{PT.retryStart}</button>
: <button type="button" className="btn btn-sm" onClick={onGoSource} data-profile-go-source="">{PT.goSource}</button>
return (
<Notice tone={b.kind === 'busy' || b.kind === 'nothing' ? 'info' : 'warn'} attr={{ 'data-profile-blocked': b.kind }}>
<p className="font-medium">{PT.blocked[b.kind]}</p>
<p className="font-medium">{PT.blocked[nothingObserve ? 'nothingObserve' : b.kind]}</p>
<p className="mt-0.5 break-words text-dim">{b.message}</p>
{b.kind === 'busy' && b.active && (
<p className="tnum mt-0.5 text-dim" data-profile-busy-at="">{PT.busyAt(b.active.created_by, b.active.done, b.active.total)}</p>
)}
<p className="mt-0.5 text-dim">{PT.blockedNext[b.kind]}</p>
<div className="mt-2">{action}</div>
<p className="mt-0.5 text-dim">{PT.blockedNext[nothingObserve ? 'nothingObserve' : b.kind]}</p>
{action && <div className="mt-2">{action}</div>}
</Notice>
)
}
+13 -3
View File
@@ -167,9 +167,19 @@ export const isProfilable = (r: Pick<CatalogTableRow, 'in_schema' | 'is_view' |
/** 服务端「需要剖析」的口径:能剖析、并且 profile_due(从没剖析过、上次没做完、之后出现了新的待核实关系) */
export const isProfileDue = (r: CatalogTableRow): boolean => isProfilable(r) && r.profile_due === true
/** 范围的几张表:needed 需要剖析的、all 全部能剖析的(服务端挑表的顺序:有待核实关系的在前,再按使用次数) */
export function profileScopeTables(rows: CatalogTableRow[], scope: 'needed' | 'all'): string[] {
return rows.filter(scope === 'needed' ? isProfileDue : isProfilable).map((r) => r.table_name)
/** 能不能做取值观察:在表结构里、不是视图、有数据目录(version > 0)。同服务端 _pick_observing 的前两条;观察到期没有
* (上一次观察在 PROFILE_OBSERVE_EVERY_H 小时以前)清单里没有,由服务端判 */
export const isObservable = (r: Pick<CatalogTableRow, 'in_schema' | 'is_view' | 'version'>): boolean =>
r.in_schema && !r.is_view && r.version > 0
/** 上一次观察多久以前才再观察(同服务端 catalog_profile_observe.OBSERVE_EVERY_H) */
export const PROFILE_OBSERVE_EVERY_H = 12
/** 范围的几张表:needed 需要剖析的、all 全部能剖析的(服务端挑表的顺序:有待核实关系的在前,再按使用次数)、observe 有数据
* 目录、可以观察的(其中最近观察过的服务端这次不观察,张数是上限) */
export function profileScopeTables(rows: CatalogTableRow[], scope: 'needed' | 'all' | 'observe'): string[] {
const test = scope === 'needed' ? isProfileDue : scope === 'observe' ? isObservable : isProfilable
return rows.filter(test).map((r) => r.table_name)
}
/** 因为运行原因没查成的项(计入 unfinished、使表成为部分完成);其余跳过的原因是按规则不查 */
+1 -1
View File
@@ -21,7 +21,7 @@ export const PROFILE_POLL_MS = 1500
export const retryableTasks = (j: CatalogProfileJob): CatalogProfileTaskRow[] => j.tasks.filter((t) => PROFILE_RETRYABLE.has(t.status))
export interface ProfileStartBody { tables?: string[]; scope?: 'needed' | 'all' }
export interface ProfileStartBody { tables?: string[]; scope?: 'needed' | 'all' | 'observe' }
export function useProfileJob(sourceId: string, active: CatalogActiveProfileJob | null | undefined, on: {
/** 这一次取回来新结束的表(写进目录了,或者失败、停止了):重取清单,正在看的表在里面就重新载入 */
+6 -2
View File
@@ -221,7 +221,8 @@ function Overview({ run }: { run: EvalRun }) {
</div>
<Section title={O.verdicts} attr="verdicts">
<p>{distText(s.verdicts as Record<string, number>, T.verdict)}</p>
{!!s.not_run && <p className="text-dim">{O.notRun(s.not_run)}</p>}
{!!s.not_run && <p className="text-dim" data-eval-not-run={s.not_run}>{O.notRun(s.not_run)}</p>}
{!!s.errors && <p className="text-dim" data-eval-errors={s.errors}>{O.errors(s.errors)}</p>}
<p className="text-dim">{O.outcomes}:{distText(s.outcomes as Record<string, number>, T.outcome)}</p>
{s.text && Object.keys(s.text).length > 0 && <p className="text-dim">{O.text}:{distText(s.text as Record<string, number>, T.textOutcome)}</p>}
</Section>
@@ -370,7 +371,10 @@ function Results({ id, live, focusCase }: { id: string; live: boolean; focusCase
{r.arm && <div className="text-2xs text-faint">{T.run.arm(r.arm)}</div>}
</td>
<td className="max-w-xs px-2 py-1.5">
<div className="line-clamp-2 break-words">{r.question ?? '—'}</div>
<div className="line-clamp-2 break-words" data-eval-result-question="">{r.question ?? '—'}</div>
{r.question_template && r.question_template !== r.question && (
<div className="line-clamp-2 break-words text-2xs text-faint" data-eval-result-template="">{R.questionTemplate(r.question_template)}</div>
)}
{r.path && <div className="text-2xs text-faint">{R.path}:{T.path[r.path]}</div>}
</td>
<td className="px-2 py-1.5">
+8
View File
@@ -393,11 +393,14 @@ export interface EvalSpend {
export type EvalStatus = 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled' | 'interrupted'
export type EvalTrigger = 'manual' | 'schedule' | 'pair'
/** 一臂的计数(服务端 runner._counts):done 是真的跑过的(评完、出错),not_run 是停止、中断、到了上限、止损没跑的,
* errors 是出错的(不含没跑的);done + not_run + running + pending = total */
export interface EvalCounts {
total?: number
done?: number
running?: number
pending?: number
not_run?: number
passed?: number
failed?: number
ungradable?: number
@@ -428,7 +431,9 @@ export interface EvalSummary {
done?: number
cases?: number
verdicts?: Partial<Record<EvalVerdict, number>>
/** 没跑的(停止、中断、到了上限、止损没开评);出错的另在 errors */
not_run?: number
errors?: number
accuracy?: EvalRate
traced_accuracy?: EvalRate
outcomes?: Partial<Record<EvalOutcome, number>>
@@ -558,7 +563,10 @@ export interface EvalResult {
error: string | null
started_at: string | null
finished_at: string | null
/** 这一题实际问的问题(变量按这次评测的截至时间渲染好了);题目之后改过、又没有作答运行可取时为 null */
question?: string | null
/** 题目的原文(可能带 {{ schedule.… }} 变量的模板);题目删了为 null */
question_template?: string | null
/** 这一题的花费:助手(含复核)+ 写作 + 结论裁判,服务端加好的 */
cost?: { cost_usd: number | null; unpriced_calls: number }
}
+49 -2
View File
@@ -421,7 +421,9 @@ function dueOf(state, src, t) {
}
/** 范围 needed / all:服务端的口径(有待核实关系的在前,这里按使用次数,同清单顺序) */
const scopeTables = (state, src, scope) => state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage)
.filter((t) => (scope === 'all' ? checksOf(t) > 0 && !t.is_view : dueOf(state, src, t) != null)).map((t) => t.name)
.filter((t) => (scope === 'all' ? checksOf(t) > 0 && !t.is_view
// 取值观察:有数据目录、不是视图的表;state.observedRecently 时都算最近观察过(服务端 12 小时内不再观察)
: scope === 'observe' ? !!t.notes && !t.is_view && !state.observedRecently : dueOf(state, src, t) != null)).map((t) => t.name)
// ---------------------------------------------------------------------------
// 假服务端的起草任务:只做排队要用的部分(建、取、停),起草本身不推
@@ -560,6 +562,10 @@ function handlers(state, replies = {}) {
if ((body?.tables?.length ?? 0) > 500) return json({ detail: '一次最多剖析 500 张表' }, 422)(route)
const tables = body?.tables?.length ? [...new Set(body.tables)] : scopeTables(state, src, body?.scope ?? 'needed')
if (!tables.length) {
if (body?.scope === 'observe') {
return json({ detail: '没有要观察的表:取值观察只看有数据目录的表,这些表最近 12 小时内都观察过了(或者还没有数据目录)',
code: 'profile_nothing' }, 409)(route)
}
return json({ detail: '按「需要剖析的表」挑出 0 张表:表都剖析过、上次都做完了,也没有新的待核实关联关系。可以改选「全部表」,或指定要剖析的表',
code: 'profile_nothing' }, 409)(route)
}
@@ -793,10 +799,14 @@ await section('剖析 · 开始前:范围、个数与预算', async () => {
check('页头写「数据剖析」', (await head(page).getAttribute('aria-label')) === '数据剖析' && (await head(page).getAttribute('data-catalog-profile')) === '')
await openDialog(page)
const scopes = await box(page).locator('[data-profile-scope]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-profile-scope')}:${e.getAttribute('data-count')}`))
const observeText = await inBox(page, '[data-profile-scope="observe"]')
// 需要剖析:从没剖析过的订单、空日志表;上次没做完的访问记录、退款;剖析后有新关系的商品。全部:再加上客户,不含视图、
// 没有可剖析项的配置表、只剩目录的旧表
check('没选表、没筛选:范围是「需要剖析的 5 张表」(默认)和「全部 6 张表」', scopes.join(',') === 'needed:5,all:6'
check('没选表、没筛选:范围是「需要剖析的 5 张表」(默认)、「全部 6 张表」和「取值观察:有数据目录的 1 张表」',
scopes.join(',') === 'needed:5,all:6,observe:1'
&& await box(page).locator('[data-profile-scope="needed"] input').isChecked(), scopes.join(','))
check('……取值观察写明只观察不剖析、看哪几样、最近 12 小时内观察过的不再观察', ['取值观察:有数据目录的 1 张表', '只观察、不剖析',
'码值分布、最新业务日期、按日行数和行数', '最近 12 小时内观察过的表这次不再观察'].every((x) => observeText.includes(x)), observeText)
check('……写明需要剖析的口径(含上次是空表的不算)、全部不含视图', (await inBox(page, '[data-profile-scope="needed"]')).includes('从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系、要取码值或日期范围的列的表。上次是空表的不算')
&& (await inBox(page, '[data-profile-scope="all"]')).includes('不含视图'))
const budget = await inBox(page, '[data-profile-budget]')
@@ -840,6 +850,43 @@ await section('剖析 · 开始前:范围、个数与预算', async () => {
await close()
})
await section('剖析 · 取值观察:范围、请求与挑不出表', async () => {
// 取值观察以前只能经定时维护触发(下一轮验收):对话框的范围里多一项「取值观察」,建任务带 scope: observe;
// 有数据目录的表都在 12 小时内观察过时服务端 409 profile_nothing,说「没有要观察的表」,不给「改选全部表」(那是剖析)
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) })
await page.locator('[data-catalog-row]').first().waitFor()
await openDialog(page)
await box(page).locator('[data-profile-scope="observe"]').click()
check('选取值观察:预算按 1 张表估', (await inBox(page, '[data-profile-budget-total]')).startsWith('这次 1 张表'),
await inBox(page, '[data-profile-budget-total]'))
await page.locator('[data-profile-start]').click()
check('开始:建任务的请求带「取值观察」范围', await until(async () => (await phase(page)) === 'running')
&& JSON.stringify(lastBody(sent, /profile-jobs$/)) === JSON.stringify({ scope: 'observe' }), JSON.stringify(lastBody(sent, /profile-jobs$/)))
const job = state.pjobs.at(-1)
check('……任务的范围是 observe、只含有数据目录的订单表', job.scope === 'observe' && job.tasks.map((t) => t.table_name).join(',') === 'orders',
`${job.scope} ${job.tasks.map((t) => t.table_name).join(',')}`)
for (const t of job.tasks) settle(state, job, t.table_name, { queries: 4, elapsed_ms: 2000 })
finish(job)
check('……观察完:对话框显示结束后的结果', await until(async () => (await phase(page)) === 'done'), await phase(page))
await page.keyboard.press('Escape')
await until(async () => (await box(page).count()) === 0)
// 都在 12 小时内观察过:开始不了,说「没有要观察的表」,没有「改选全部表」
state.observedRecently = true
await openDialog(page)
await box(page).locator('[data-profile-scope="observe"]').click()
await page.locator('[data-profile-start]').click()
check('挑不出表:说「没有要观察的表」和服务端的原话(12 小时内都观察过)', await until(async () => (await inBox(page, '[data-profile-blocked]')).startsWith('没有要观察的表'))
&& (await inBox(page, '[data-profile-blocked]')).includes('最近 12 小时内都观察过了'), await inBox(page, '[data-profile-blocked]'))
check('……不给「改选全部表」(那是剖析,不是观察),可以回到上一步', await box(page).locator('[data-profile-choose-all]').count() === 0
&& await page.locator('[data-profile-back]').count() === 1)
check('写请求只有建任务', writes(sent, /./).every((s) => /profile-jobs$/.test(s.key)), JSON.stringify(writes(sent, /./).map((s) => s.key)))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('剖析 · 进行中:逐表进度、后台继续与停止', async () => {
const state = freshState()
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) })
+22 -3
View File
@@ -161,13 +161,15 @@ const RESULTS = [
scores: [{ id: 'e1', kind: 'scalar', required: true, outcome: 'traced', expected: 330, got: 330 }], traced: true, review: null, tier: null,
judge: null, looks: { model: 1, system: 1 }, calls: 2, timings: { total_ms: 41000 }, usage: {}, sql_match: { status: 'proven', aspects: [] },
details_artifact: 'd-1', current: null, error: null, started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H), question: CASE_SQL.question,
cost: { cost_usd: 0.014, unpriced_calls: 0 } },
question_template: CASE_SQL.question, cost: { cost_usd: 0.014, unpriced_calls: 0 } },
{ id: 'r-2', eval_run_id: 'ev-1', seq: 2, case_id: 'case-2', case_seq: 2, case_hash: 'h2', repeat: 0, arm: null, status: 'ok',
run_id: 'run-answer-2', gold_run_ids: ['run-gold-2'], gold: { status: 'ok' }, path: 'ran', verdict: 'fail',
scores: [{ id: 'e1', kind: 'scalar', required: true, outcome: 'wrong', expected: 330, got: 310, reason: '答案里的数和期望对不上' }], traced: false,
review: null, tier: null, judge: null, looks: { model: 1, system: 0 }, calls: 2, timings: { total_ms: 60000 }, usage: {},
sql_match: { status: 'related', aspects: ['time'] }, details_artifact: 'd-2', current: null, error: '答案里的数和期望对不上',
started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H), question: CASE_CAL.question, cost: { cost_usd: 0.02, unpriced_calls: 0 } },
started_at: iso(NOW - 2 * H), finished_at: iso(NOW - H), cost: { cost_usd: 0.02, unpriced_calls: 0 },
// 带变量的题:question 是实际问的(服务端按这次评测的截至时间渲染好),模板原文另放
question: '10 月 5 日到 10 月 11 日的周销售额?', question_template: '{{ schedule.last_week_start }} 到 {{ schedule.last_week_end }} 的周销售额?' },
{ id: 'r-3', eval_run_id: 'ev-1', seq: 3, case_id: 'case-3', case_seq: 3, case_hash: 'h3', repeat: 0, arm: null, status: 'ok',
run_id: null, gold_run_ids: [], gold: null, path: 'asked', verdict: 'pass', scores: [{ id: 'e1', kind: 'ask', required: true, outcome: 'traced' }],
traced: true, review: null, tier: null, judge: null, looks: { model: 0, system: 0 }, calls: 1, timings: { total_ms: 9000 }, usage: {},
@@ -254,6 +256,9 @@ function reply(state, method, path, url, body) {
if (out) return out
}
if (path === '/auth/me' && state.me) return { json: state.me }
// 「提取模板」去画布之后,画布页给这张图起一段画布对话(POST /conversations):早晚不定,以前偶尔赶在检查结束前发出去、
// 漏到服务端。照 check-ledger 的写法在这里接住,不进写请求的清单(不是评测页发的)
if (method === 'POST' && path === '/conversations') return { json: { id: 'cv-eval', kind: body?.kind ?? 'canvas', title: '', turns: [] } }
if (method === 'GET' && path === '/datasources') return { json: [DS] }
if (method === 'GET' && path === '/evals/suites') return { json: { suites: state.suites } }
if (method === 'POST' && path === '/evals/suites') {
@@ -357,7 +362,11 @@ function reply(state, method, path, url, body) {
}
if (method === 'POST' && rest === '/stop') {
write()
Object.assign(ev, { status: 'cancelled', running: false, current: [], stop_reason: null,
// 服务端(runner._counts、_close):停下的原因记 stop_reason、不当成出错;没跑的题单独计 not_run,不进 errors,done 不等于 total
Object.assign(ev, { status: 'cancelled', running: false, current: [], stop_reason: '评测已停止', error: null,
counts: { total: 3, done: 1, not_run: 2, running: 0, pending: 0, passed: 1, failed: 0, ungradable: 0, skipped: 0, errors: 0 },
summary: { ...clone(SUMMARY), items: 3, done: 3, verdicts: { pass: 1, fail: 0, ungradable: 0, skipped: 0 }, not_run: 2, errors: 0,
accuracy: RATE(1, 1), traced_accuracy: RATE(1, 1) },
actions: { stop: false, retry: true, rescore: true, pin: false, unpin: false, open: 2, cleaned: false } })
return { json: ev }
}
@@ -738,6 +747,11 @@ await section('评测详情:进度、正在做什么、停止和重试、重
await page.locator('[data-eval-retry]').waitFor()
check('停止之后:服务端说能重试,给「重试没做完的」,悬停写几项', (await page.locator('[data-eval-retry]').getAttribute('title')).includes('2 项')
&& writes(state, /\/stop$/).length === 1)
check('……停下的原因照服务端写「评测已停止」,不当成出错;没评的 2 项单列,不算出错', await until(async () =>
(await page.locator('[data-eval-stop-reason]').count()) === 1)
&& (await text(page, '[data-eval-stop-reason]')).includes('评测已停止') && await page.locator('[data-eval-error]').count() === 0
&& (await text(page, '[data-eval-not-run]')).startsWith('2 项没评') && await page.locator('[data-eval-errors]').count() === 0,
await text(page, '[data-eval-run-head]'))
await page.locator('[data-eval-retry]').click()
await until(() => writes(state, /\/retry$/).length === 1)
check('重试:发了一次', writes(state, /\/retry$/).length === 1)
@@ -772,6 +786,11 @@ await section('逐题结果、和上一次比、A/B 配对表', async () => {
check('?case= 打开逐题、定位并展开那道题', await page.locator('[data-eval-result-detail="r-2"]').count() === 1)
check('逐题:结论、期望和答案里的数、SQL 差在哪', t2.includes('不通过') && t2.includes('330') && t2.includes('310') && t2.includes('时间口径'), t2)
check('一题的花费照服务端加好的写', (await page.locator('[data-eval-result="r-1"]').innerText()).includes('$0.014'))
check('逐题的问法写实际问的(变量渲染好了),模板原文另起一行', await text(page, '[data-eval-result="r-2"] [data-eval-result-question]')
=== '10 月 5 日到 10 月 11 日的周销售额?'
&& (await text(page, '[data-eval-result="r-2"] [data-eval-result-template]')).startsWith('题目原文:{{ schedule.last_week_start }}')
&& await page.locator('[data-eval-result="r-1"] [data-eval-result-template]').count() === 0,
await text(page, '[data-eval-result="r-2"] [data-eval-result-question]'))
check('点开去运行详情', await page.locator('[data-eval-result-detail="r-2"] [data-eval-result-run]').getAttribute('href') === '/runs/run-answer-2')
await page.locator('[data-eval-result-details]').waitFor()
check('展开的明细写作答的经过和答案原文(明细工件)', (await text(page, '[data-eval-result-details]')).includes('自动重来了 1 次')
+4 -4
View File
@@ -450,9 +450,9 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
// 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对
const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts'))
// [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)]。变体挑的是收尾版差异说明下各有代表性
// 的几种(backend/tests/test_dual_check_fixture.py 的模块说明):同一列上的时间范围不同只给兜底(fallback)
// 的几种(backend/tests/test_dual_check_fixture.py 的模块说明):套着 DATE() 的时间范围不同只给兜底(fallback)
const cases = [
['fallback', '说不清的(同一列上的时间范围不同,只给兜底)'],
['fallback', '说不清的(套着 DATE() 的时间范围不同,只给兜底)'],
['two_filters', '差两个筛选条件'],
['join', '差在关联方式'],
['parse', '一边解析失败'],
@@ -492,7 +492,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
&& note.includes('按不同的列筛时间') && note.includes('采用的一边所用的时间列'), note)
}
if (name === 'fallback') {
// 收尾版差异说明归纳不了同一列上的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL
// 收尾版差异说明归纳不了套着函数的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL
check('……写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异'
&& await gmv.locator('[data-dual-diff-item]').count() === 0, await text(gmv.locator('[data-dual-fallback]')))
check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true')
@@ -532,7 +532,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
const marked = [...m.normDel, ...m.normAdd]
check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1
&& !marked.some((l) => l.includes(">= '2026-09-01'"))
&& marked.every((l) => l.includes("orders.paid_at < '2026-09-")), marked.join(' ‖ '))
&& marked.every((l) => l.includes("DATE(orders.paid_at) < '2026-09-")), marked.join(' ‖ '))
}
if (name === 'two_filters') {
check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")
+10 -1
View File
@@ -50,6 +50,9 @@ SETTINGS = ProfileSettings(enabled=True, table_max_queries=40, table_max_s=60, q
N_ORDERS, N_EVENTS_OLD, N_EVENTS_NEW = 40, 10, 1500
BASE = _dt.datetime(2026, 9, 1, 10, 0, 0)
WINDOW_FROM = "2026-09-05"
#: 按日行数的上界(观察当天,含):WINDOW_UNTIL 在全部夹具日期之后;WINDOW_CUT 落在中间,之后的日子不进窗口
WINDOW_UNTIL = "2026-09-30"
WINDOW_CUT = "2026-09-07"
failures: list[str] = []
@@ -80,6 +83,8 @@ EXPECT = {
"pay_way": Counter(o["pay_way"] for o in ORDERS),
"paid_at": (min(o["paid_at"] for o in VALID), max(o["paid_at"] for o in VALID)),
"day_volume": Counter(o["paid_at"].date().isoformat() for o in IN_WINDOW),
"day_volume_cut": Counter(o["paid_at"].date().isoformat() for o in IN_WINDOW
if o["paid_at"].date().isoformat() <= WINDOW_CUT),
"window_status": Counter(o["status"] for o in IN_WINDOW),
}
@@ -354,9 +359,13 @@ async def run_target(t: Target) -> None:
# ---- 取值观察的新方法
tbl = d.table(orders, "orders")
paid_col = d.column(orders, _key(orders, "paid_at"))
res = await data_engine.run_query(source, d.day_volume_sql(tbl, paid_col, WINDOW_FROM))
res = await data_engine.run_query(source, d.day_volume_sql(tbl, paid_col, WINDOW_FROM, WINDOW_UNTIL))
got_days = Counter({_day(row[0]): int(row[1]) for row in res.rows})
check(got_days == EXPECT["day_volume"], f"按日行数和手算一致(无效日期不算进窗口):{dict(got_days)}")
res = await data_engine.run_query(source, d.day_volume_sql(tbl, paid_col, WINDOW_FROM, WINDOW_CUT))
got_cut = Counter({_day(row[0]): int(row[1]) for row in res.rows})
check(got_cut == EXPECT["day_volume_cut"],
f"按日行数只数到观察当天(含当天的时分秒,之后的不算):{dict(got_cut)}")
status_col = d.column(orders, _key(orders, "status"))
res = await data_engine.run_query(source, d.window_value_counts_sql(tbl, status_col, paid_col, WINDOW_FROM, 21))
got_window = Counter({str(row[0]).rstrip(" "): int(row[1]) for row in res.rows})