diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index f5a85a6b..8810486c 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -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) diff --git a/backend/app/api/evals.py b/backend/app/api/evals.py index 3c722aaa..37da3987 100644 --- a/backend/app/api/evals.py +++ b/backend/app/api/evals.py @@ -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 已含复核)+ 写作 + 结论裁判,和这次评测记在账上的同一份;估不出金额的调用另计 次数(界面写「≥」或「金额无法估算」)。加在服务端:界面不算数。""" diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index f06d8b4a..606f82ad 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -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 diff --git a/backend/app/api/shares.py b/backend/app/api/shares.py index 4bcc1b94..27006a5a 100644 --- a/backend/app/api/shares.py +++ b/backend/app/api/shares.py @@ -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)) # -------------------------------------------------------------------------- diff --git a/backend/app/core/job_runtime.py b/backend/app/core/job_runtime.py index 98b641a9..f1f55b10 100644 --- a/backend/app/core/job_runtime.py +++ b/backend/app/core/job_runtime.py @@ -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 diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index ed63b2ed..7ea86279 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -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: diff --git a/backend/app/data/catalog_profile_jobs.py b/backend/app/data/catalog_profile_jobs.py index e24d2c19..37a4b097 100644 --- a/backend/app/data/catalog_profile_jobs.py +++ b/backend/app/data/catalog_profile_jobs.py @@ -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"] diff --git a/backend/app/data/catalog_profile_observe.py b/backend/app/data/catalog_profile_observe.py index 78b07a31..0663312e 100644 --- a/backend/app/data/catalog_profile_observe.py +++ b/backend/app/data/catalog_profile_observe.py @@ -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) diff --git a/backend/app/data/catalog_review.py b/backend/app/data/catalog_review.py index 43e27594..78eb3438 100644 --- a/backend/app/data/catalog_review.py +++ b/backend/app/data/catalog_review.py @@ -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() diff --git a/backend/app/data/source_maintenance.py b/backend/app/data/source_maintenance.py index a2bba31e..aa8597bf 100644 --- a/backend/app/data/source_maintenance.py +++ b/backend/app/data/source_maintenance.py @@ -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 diff --git a/backend/app/data/source_objects.py b/backend/app/data/source_objects.py index 058fc989..b938a7d4 100644 --- a/backend/app/data/source_objects.py +++ b/backend/app/data/source_objects.py @@ -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", diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 1ab1848e..ffb7ae58 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -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 给出规范化之后不同 的行(没有规范形式可比时给原文的逐行对照)。 diff --git a/backend/app/data/value_drift.py b/backend/app/data/value_drift.py index a165f995..432d947a 100644 --- a/backend/app/data/value_drift.py +++ b/backend/app/data/value_drift.py @@ -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 diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index ac31b861..c3acfd8c 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -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": "运行仍在进行,无需恢复。", diff --git a/backend/app/eval/runner.py b/backend/app/eval/runner.py index 03224dcd..3412c005 100644 --- a/backend/app/eval/runner.py +++ b/backend/app/eval/runner.py @@ -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 diff --git a/backend/app/eval/stats.py b/backend/app/eval/stats.py index 395eace6..7e4f6a8e 100644 --- a/backend/app/eval/stats.py +++ b/backend/app/eval/stats.py @@ -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), diff --git a/backend/app/ledger/certify.py b/backend/app/ledger/certify.py index cf57526b..f4384c2b 100644 --- a/backend/app/ledger/certify.py +++ b/backend/app/ledger/certify.py @@ -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]}) diff --git a/backend/app/ledger/stats.py b/backend/app/ledger/stats.py index 180d984a..64f37605 100644 --- a/backend/app/ledger/stats.py +++ b/backend/app/ledger/stats.py @@ -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 diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 6ec395f5..462f4643 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -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")}} diff --git a/backend/app/providers/mock_model.py b/backend/app/providers/mock_model.py index 0ad18dd2..77870282 100644 --- a/backend/app/providers/mock_model.py +++ b/backend/app/providers/mock_model.py @@ -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: diff --git a/backend/app/providers/usage.py b/backend/app/providers/usage.py index 9404039a..56298a2c 100644 --- a/backend/app/providers/usage.py +++ b/backend/app/providers/usage.py @@ -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: diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index ca79a969..fe249549 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -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: diff --git a/backend/tests/fixtures/dialect/mariadb.sql b/backend/tests/fixtures/dialect/mariadb.sql index b837c0b0..75bdc227 100644 --- a/backend/tests/fixtures/dialect/mariadb.sql +++ b/backend/tests/fixtures/dialect/mariadb.sql @@ -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 diff --git a/backend/tests/fixtures/dialect/mysql.sql b/backend/tests/fixtures/dialect/mysql.sql index 17f0f5da..57141703 100644 --- a/backend/tests/fixtures/dialect/mysql.sql +++ b/backend/tests/fixtures/dialect/mysql.sql @@ -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 diff --git a/backend/tests/fixtures/dialect/oracle.sql b/backend/tests/fixtures/dialect/oracle.sql index ba09c3d6..d8c1de40 100644 --- a/backend/tests/fixtures/dialect/oracle.sql +++ b/backend/tests/fixtures/dialect/oracle.sql @@ -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 diff --git a/backend/tests/fixtures/dialect/postgresql.sql b/backend/tests/fixtures/dialect/postgresql.sql index 995eb3f3..0a9b9ca4 100644 --- a/backend/tests/fixtures/dialect/postgresql.sql +++ b/backend/tests/fixtures/dialect/postgresql.sql @@ -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 diff --git a/backend/tests/fixtures/dialect/sqlite.sql b/backend/tests/fixtures/dialect/sqlite.sql index 44e0a904..1947d6d2 100644 --- a/backend/tests/fixtures/dialect/sqlite.sql +++ b/backend/tests/fixtures/dialect/sqlite.sql @@ -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 diff --git a/backend/tests/test_catalog_observe.py b/backend/tests/test_catalog_observe.py index 68d07455..689cfe74 100644 --- a/backend/tests/test_catalog_observe.py +++ b/backend/tests/test_catalog_observe.py @@ -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:大表、业务日期没有索引。""" diff --git a/backend/tests/test_catalog_profile_units.py b/backend/tests/test_catalog_profile_units.py index 22933754..7ce3c114 100644 --- a/backend/tests/test_catalog_profile_units.py +++ b/backend/tests/test_catalog_profile_units.py @@ -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) diff --git a/backend/tests/test_catalog_schema_truncated.py b/backend/tests/test_catalog_schema_truncated.py index f0c99435..beb9b500 100644 --- a/backend/tests/test_catalog_schema_truncated.py +++ b/backend/tests/test_catalog_schema_truncated.py @@ -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"] diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 66fdf79b..c6bff52d 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -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 " diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index cbdea294..f10f100e 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -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,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 diff --git a/backend/tests/test_eval_runner.py b/backend/tests/test_eval_runner.py index 8e348397..c6e0ee79 100644 --- a/backend/tests/test_eval_runner.py +++ b/backend/tests/test_eval_runner.py @@ -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, diff --git a/backend/tests/test_ledger_api.py b/backend/tests/test_ledger_api.py index 2e5eeff2..3814e4e5 100644 --- a/backend/tests/test_ledger_api.py +++ b/backend/tests/test_ledger_api.py @@ -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 diff --git a/backend/tests/test_ledger_certify.py b/backend/tests/test_ledger_certify.py index e91dee59..4444064c 100644 --- a/backend/tests/test_ledger_certify.py +++ b/backend/tests/test_ledger_certify.py @@ -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 diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 0052f306..7e7232c7 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -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 diff --git a/backend/tests/test_profile_dialect_golden.py b/backend/tests/test_profile_dialect_golden.py index ebdd546e..ffd9b366 100644 --- a/backend/tests/test_profile_dialect_golden.py +++ b/backend/tests/test_profile_dialect_golden.py @@ -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"))), diff --git a/backend/tests/test_run_rejected.py b/backend/tests/test_run_rejected.py index 1da7e538..18f5cf35 100644 --- a/backend/tests/test_run_rejected.py +++ b/backend/tests/test_run_rejected.py @@ -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(封存不改写):失败的那一步上最近一条处理完的待办是驳回,也不许接着跑。 别的失败照旧能接着跑(这里断点不在,说的是没有断点,不是被驳回)。""" diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index 28d110d0..49738185 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -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() diff --git a/backend/tests/test_source_maintenance.py b/backend/tests/test_source_maintenance.py index 468c580c..73f76413 100644 --- a/backend/tests/test_source_maintenance.py +++ b/backend/tests/test_source_maintenance.py @@ -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: diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 7a9bbf93..0b9f35cf 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -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] diff --git a/backend/tests/test_sql_text_diff.py b/backend/tests/test_sql_text_diff.py index f49f670d..87a9680d 100644 --- a/backend/tests/test_sql_text_diff.py +++ b/backend/tests/test_sql_text_diff.py @@ -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'") diff --git a/backend/tests/test_usage_meter.py b/backend/tests/test_usage_meter.py index ed0c35b5..828a8130 100644 --- a/backend/tests/test_usage_meter.py +++ b/backend/tests/test_usage_meter.py @@ -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): diff --git a/backend/tests/test_value_drift.py b/backend/tests/test_value_drift.py index 6fa80ef9..9d45e446 100644 --- a/backend/tests/test_value_drift.py +++ b/backend/tests/test_value_drift.py @@ -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) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63dc10ed..13c722d2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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) => diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 6024462e..e2003b81 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -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": { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index a1143961..cd426a7a 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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, blockedNext: { @@ -3239,6 +3245,7 @@ export const PROFILE_TEXT = { busy: '同一个数据源同一时间只有一个剖析任务:可以查看它的进度,等它结束后再开始新的剖析。', tampered: '请重新上传表格,或联系管理员核对数据文件。', nothing: '可以改选「全部表」,或在表清单中选定要剖析的表。', + nothingObserve: '观察过的表要过一段时间才再观察;要剖析,可以回到上一步改选「需要剖析的表」或「全部表」。', other: '请稍后重试。', } as Record, busyAt: (who: string | null, done: number, total: number) => diff --git a/frontend/src/lib/termsEval.ts b/frontend/src/lib/termsEval.ts index 04e268cd..348f9404 100644 --- a/frontend/src/lib/termsEval.ts +++ b/frontend/src/lib/termsEval.ts @@ -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: '答案里的数', diff --git a/frontend/src/pages/catalog/ProfileDialog.tsx b/frontend/src/pages/catalog/ProfileDialog.tsx index 24d253dc..5eba1092 100644 --- a/frontend/src/pages/catalog/ProfileDialog.tsx +++ b/frontend/src/pages/catalog/ProfileDialog.tsx @@ -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(() => { @@ -260,8 +266,9 @@ export function ProfileDialog({
{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 (

{distText(s.verdicts as Record, T.verdict)}

- {!!s.not_run &&

{O.notRun(s.not_run)}

} + {!!s.not_run &&

{O.notRun(s.not_run)}

} + {!!s.errors &&

{O.errors(s.errors)}

}

{O.outcomes}:{distText(s.outcomes as Record, T.outcome)}

{s.text && Object.keys(s.text).length > 0 &&

{O.text}:{distText(s.text as Record, T.textOutcome)}

}
@@ -370,7 +371,10 @@ function Results({ id, live, focusCase }: { id: string; live: boolean; focusCase {r.arm &&
{T.run.arm(r.arm)}
} -
{r.question ?? '—'}
+
{r.question ?? '—'}
+ {r.question_template && r.question_template !== r.question && ( +
{R.questionTemplate(r.question_template)}
+ )} {r.path &&
{R.path}:{T.path[r.path]}
} diff --git a/frontend/src/typesEval.ts b/frontend/src/typesEval.ts index 82f851be..39d3e185 100644 --- a/frontend/src/typesEval.ts +++ b/frontend/src/typesEval.ts @@ -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> + /** 没跑的(停止、中断、到了上限、止损没开评);出错的另在 errors */ not_run?: number + errors?: number accuracy?: EvalRate traced_accuracy?: EvalRate outcomes?: Partial> @@ -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 } } diff --git a/scripts/check-catalog-profile.mjs b/scripts/check-catalog-profile.mjs index 6fac3c71..7d45bde2 100644 --- a/scripts/check-catalog-profile.mjs +++ b/scripts/check-catalog-profile.mjs @@ -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) }) diff --git a/scripts/check-evals.mjs b/scripts/check-evals.mjs index 50e238e7..db3f53b1 100644 --- a/scripts/check-evals.mjs +++ b/scripts/check-evals.mjs @@ -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 次') diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 0ae030a4..59e96516 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -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'") diff --git a/scripts/dialect_it.py b/scripts/dialect_it.py index 86538338..f5621d42 100644 --- a/scripts/dialect_it.py +++ b/scripts/dialect_it.py @@ -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})