Files
ThearchyHelios 271bc6e782 口径标签逐格判:引用了好几列的查询,每个数按它自己那一格标
真实模型下问「上周的销售额是多少」,助手写 SUM(amount) AS 销售额, COUNT(*) AS 订单数, MIN / MAX(paid_date) AS 区间首日 /
末日,报告四列都引用了,口径「周销售」只取 SUM(amount)。按「全部引用列」投影后还是 4 列,对不上口径的 1 列,整条比、
四个数都标相关口径,说明整段兜底。

- ledger/match._cells / _cell(algo 4):整条(含按全部引用列投影)没证明、又引用了这条查询两列以上的,被引用的每一列单独
  投影到那一列,和每份口径的每个指标读的那一列(card_reads;口径版本没有口径卡的按取数的每一列)逐个比,仍只走
  match_template / explain_sql_diff,上次的投影规则照用:投影不了(分组列、DISTINCT……)、过了时限、不可比的格照用整条
  查询的那一个候选。结果记在 queries[].cells {列名: {status, candidates}},另记 one_row(sql_semantics.one_row:不分组的
  聚合,一定只出一行)。整条这一层的结论、候选一字不改;老工件没有 cells,照旧按整条读。
- ledger/labels:单元格按它那一格的结论标(_view);指标用了同一条查询的好几格时,只有那条查询一定只出一行才把逐格的
  结论合起来用(多行的结果里每一列各自对得上,不等于每一行的几列一起对得上)。整题照拍板 L1 不变:每个数都证明来自
  同一份口径才等价;一格证明了、别的格只是相关的整题不等价,原因写「有的数和相关口径不一致,不来自口径」(以前一律说
  「这次的推理」)。出具入口照 ledger/issue 现有判法只在整题等价时给,没有放宽。
- 逐格证明了的口径各处都算上(labels.candidates_of):工件 meta 的 calibers(召回按旧版本反查)、ledger/uses 的使用记录、
  召回时找原答案对应的那一格、E4;E1 另数各种结论各几格(cells),E2 的兜底比例按标签上给人看的那一份说明数。
- GET /runs/{id}/ledger 的 queries 带上逐格的 cells(遮罩同候选);证据片段的 ledger 键、轮次摘要的计数本来就按每个数的
  标签,数据变了、形状不变。前端只补类型;check-ledger 加一段:同一条查询一格来自口径(正文挂小标记)、一格相关(差在哪
  先说多查了几列),整题不等价不给出具。
- 评测逐题比 SQL(eval/score.sql_match)取和期望配上的那一格(score.matched_cells:评分时配上的格),没给时取逐格证明了
  的那一格;结果带 column。runner、rescore 传它。

测试:tests/test_ledger_cells.py(同一条查询一列等价、一列不等价各自标对;两份口径只证明了一份标那一份;口径多个指标按列
配对;指标跨两格只在一行的查询上用逐格结论;分组列、DISTINCT 逐格也不投影;照真实例子造的虚构库真跑一遍:销售额标等价
已证明,订单数、首末日标相关,整题不等价、不给出具、不自动出具,证据片段的 ledger 键一致;评测取配上的那一格)。去掉逐格
再跑,10 条失败;去掉「一行才合起来用」、去掉评测取格,各 1 条失败。
2026-10-17 13:07:19 +08:00

401 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""口径账本自身给出的统计 E1–E4(docs/design/next-round/02-口径账本.md 做法 10,总方案 X2:只有这一份实现)。
全部从比对工件(kind=caliber_match)、运行、查询快照和审计现算,不另存:另建统计表或埋点,数据和账本就成了两份。评测页
「口径命中与重复度」直接调 compute_stats(同一个函数、同一套口径),评测细案的 eval/signals.py 撤销。
范围:since / until 按运行的发起时间;origins 缺省是比对的那几种探索运行(ledger/match.EXPLORE_ORIGINS);只算「计入统计」的
运行(engine/origin.counted_runs:评测、对账、召回这些系统派生的不算)。run_ids 给了就只算这几次(评测传它自己挑的运行,
这时不再按来源和计入统计筛——调用方点名的就是要算的)。
- E1 等价命中:比对过的查询里 proven / composed / related / none / skipped 各几条(skipped 单独列,不混进 none),有候选口径
的查询里各自的比率;证明等价的绑定是在哪一级配上的,其中几份是按引用的列投影之后才证明的(proven_projected,ledger/match
的 _projected);逐格比过的格(algo 4,ledger/match._cells)各种结论各几格(cells);没证明的原因(不可比、参数绑不上、
配上了但不等价);不等价的差异类型分布(time / filter / aggregate / join / other / parse,每份口径的差异每类只计一次;
投影之后那句「这次多查了 N 列」不是差异,不计);逐个数的标签各几个、整题等价几次。
E2 的兜底比例按标签上给人看的那一份说明数:逐格比过的按逐格的(labels.candidates_of 的 shown)。
- E2 对账:对账运行(origin=reconcile,第 3 波起由 ledger/reconcile 发起)发起、完成、失败各几次,对过账的答案几个,其中参数
经人核对过的、对照的指标由人选的各几次;相关口径的差异说明里兜底说明的占比;对账结果上「说明有用 / 看不懂」的反馈
(第 4 波,ledger/feedback:对账运行上的封存后事件 ledger.feedback,每人只算最后一次)各几个、有用的比率。
- E3 重复度:问数据运行里执行过的查询按(数据源、normalize_sql 文本)分组,出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS
段对话的算重复的形状,给出这样的形状几个、有重复形状的运行占几成;问题聚类(app/chat/questions:全站唯一的聚类,只取问题
原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(第 3 波,ledger/certify):固化成
口径草稿几份(origin.freeze 的工作流)、其中发布了几份、点了「不是口径」几项。
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数——只算图上有源头口径卡的版本,没有口径卡
的普通发布工作流的正式运行不算);每次发布(口径升到某一版)召回了几组、
收过尾的几组、其中数变了的几组和比例、重算失败的几组(ledger/recall:召回运行和它收尾时记的 ledger.recalled,第 4 波)。
按召回运行的发起时间筛窗口;表结构变了之后的口径试算不算召回。
"""
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Collection, Sequence
from datetime import datetime
from typing import Any
from sqlalchemy import func, select
from app.ledger import labels as ledger_labels
from app.ledger import store
#: 重复的门槛:同一个形状出现在几次运行里、来自几段对话(同认证队列的进队门槛)
REPEAT_RUNS = 3
REPEAT_CONVERSATIONS = 2
#: 差异类型怎么归到 E1 的几类
ASPECT_GROUP = {"time": "time", "time_range": "time", "filter": "filter", "aggregate": "aggregate",
"group_by": "aggregate", "join": "join", "tables": "join", "parse": "parse"}
DIFF_GROUPS = ("time", "filter", "aggregate", "join", "other", "parse")
#: 差在哪里不算差异的条目:投影之后的「这次多查了 N 列,不影响这个数」
NOT_DIFFS = ("projection",)
STATUSES = ("proven", "composed", "related", "none", "skipped")
#: 聚类最多取几个问题(同治理接口的上限)
QUESTION_LIMIT = 500
def _ratio(part: int, whole: int) -> float | None:
return round(part / whole, 4) if whole else None
def _scope(stmt: Any, *, since: datetime | None, until: datetime | None, origins: Sequence[str] | None,
run_ids: Collection[str] | None) -> Any:
from app.db.models import Run
from app.engine.origin import counted_runs
from app.ledger.match import EXPLORE_ORIGINS
if run_ids is not None:
return stmt.where(Run.id.in_(list(run_ids)))
stmt = stmt.where(counted_runs(), Run.origin_kind.in_(list(origins or EXPLORE_ORIGINS)))
if since is not None:
stmt = stmt.where(Run.created_at >= since)
if until is not None:
stmt = stmt.where(Run.created_at < until)
return stmt
async def _matches(session: Any, **scope: Any) -> list[dict[str, Any]]:
"""范围里每次运行最新的一份比对工件的内容(按工件引用的时间取最新;读不出的跳过)。"""
from app.core.artifact_store import load
from app.db.models import Artifact, Run
stmt = select(Artifact.id, Artifact.run_id, Artifact.created_at).join(Run, Run.id == Artifact.run_id) \
.where(Artifact.kind == store.KIND)
latest: dict[str, tuple[Any, str]] = {}
for artifact, run_id, at in (await session.execute(_scope(stmt, **scope))).all():
if run_id not in latest or (at and latest[run_id][0] and at > latest[run_id][0]):
latest[run_id] = (at, artifact)
def read() -> list[dict[str, Any]]:
out = []
for _, artifact in latest.values():
try:
content = load(artifact)
except ValueError:
continue
if isinstance(content, dict) and content.get("format") == store.FORMAT:
out.append(content)
return out
return await asyncio.to_thread(read)
def e1_of(contents: list[dict[str, Any]]) -> dict[str, Any]:
"""E1 等价命中(见模块说明)。纯函数:评测拿自己的比对结果也能算。"""
status = Counter({s: 0 for s in STATUSES})
with_candidates = Counter({s: 0 for s in STATUSES})
via: Counter[str] = Counter()
projected = 0
cells = Counter({s: 0 for s in STATUSES})
unproven: Counter[str] = Counter()
diffs = Counter({g: 0 for g in DIFF_GROUPS})
labels = Counter({k: 0 for k in ledger_labels.KINDS})
whole = 0
queries = 0
for content in contents:
for query in content.get("queries") or []:
queries += 1
s = str(query.get("status") or "")
status[s] += 1
for cell in ledger_labels.cells_of(query):
cells[str(cell.get("status") or "")] += 1
if query.get("candidates"):
with_candidates[s] += 1
for cand in query.get("candidates") or []:
if cand.get("status") == "proven":
via[str(cand.get("via") or "parsed")] += 1
projected += isinstance(cand.get("projection"), dict)
continue
if cand.get("status") == "skipped":
continue
if not cand.get("comparable", True):
unproven["不可比:" + str(cand.get("reason") or "")] += 1
elif cand.get("reason"):
unproven[str(cand["reason"])] += 1
else:
unproven["参数配上了,但和口径不等价"] += 1
seen = {ASPECT_GROUP.get(str(item.get("aspect")), "other")
for item in (cand.get("diff") or {}).get("items") or [] if item.get("aspect") not in NOT_DIFFS}
for group in seen:
diffs[group] += 1
for report in (content.get("reports") or {}).values():
for label in (report.get("labels") or {}).values():
labels[str(label.get("kind"))] += 1
if (content.get("whole") or {}).get("equivalent"):
whole += 1
candidates = sum(with_candidates.values())
return {
"runs": len(contents), "queries": queries, "status": dict(status),
"with_candidates": candidates,
"rates": {s: _ratio(with_candidates[s], candidates) for s in STATUSES if s != "none"},
"cells": dict(cells), "proven_via": dict(via), "proven_projected": projected,
"unproven_reasons": dict(unproven.most_common(20)),
"diff_aspects": dict(diffs),
"labels": dict(labels), "whole_equivalent": whole,
}
async def _e2(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dict[str, Any]:
from app.db.models import Run
stmt = select(Run.status, func.count()).where(Run.origin_kind == "reconcile")
if scope.get("run_ids") is not None:
stmt = stmt.where(Run.id.in_(list(scope["run_ids"])))
else:
# 对账运行是系统派生的(不计入使用统计),这里专门数的就是它:只按时间筛
if scope.get("since") is not None:
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
from app.ledger import feedback as ledger_feedback
by_status = {s: int(n) for s, n in (await session.execute(stmt.group_by(Run.status))).all()}
origins = [o for (o,) in (await session.execute(stmt.with_only_columns(Run.origin))).all() if isinstance(o, dict)]
reconcile_ids = [r for (r,) in (await session.execute(stmt.with_only_columns(Run.id))).all()]
answers = {o.get("source_run") for o in origins if o.get("source_run")}
explained = fallback = 0
for content in contents:
for query in content.get("queries") or []:
# 标签上给人看的那一份:逐格比过的按逐格的说明数(ledger_labels.candidates_of 的 shown)
for cand in ledger_labels.candidates_of(query, shown=True):
if cand.get("status") == "related" and isinstance(cand.get("diff"), dict):
explained += 1
fallback += bool(cand["diff"].get("fallback"))
return {"launched": sum(by_status.values()), "succeeded": by_status.get("succeeded", 0),
"failed": by_status.get("failed", 0), "answers": len(answers),
"inputs_checked": sum(1 for o in origins if o.get("inputs_from") == "checked"),
"pairs_chosen": sum(1 for o in origins if o.get("pairs_chosen")),
"explained": explained, "fallback": fallback,
"fallback_rate": _ratio(fallback, explained),
"feedback": await ledger_feedback.counts(session, reconcile_ids)}
#: 查询快照 → (数据源, 形状)。工件按内容寻址、不会变,记下来不会过期
_SHAPES: dict[str, tuple[str, str] | None] = {}
_SHAPES_MAX = 20000
def _shape_of(artifact: str, kinds: dict[str, str | None]) -> tuple[str, str] | None:
from app.core.artifact_store import load
from app.data.sql_semantics import normalize_sql
if artifact in _SHAPES:
return _SHAPES[artifact]
try:
snap = load(artifact)
except ValueError:
snap = None
found: tuple[str, str] | None = None
if isinstance(snap, dict) and isinstance(snap.get("sql"), str) and snap["sql"].strip():
source = str(snap.get("source") or "")
found = (source, normalize_sql(snap["sql"], kinds.get(source)).text)
if len(_SHAPES) < _SHAPES_MAX:
_SHAPES[artifact] = found
return found
async def _e3(session: Any, *, principal: Any, **scope: Any) -> dict[str, Any]:
from app.chat.questions import cluster_questions, embedder, recent_questions
from app.data.catalog import QUERY_SNAPSHOT_KIND
from app.db.models import Artifact, ConversationTurn, DataSource, Run
chat_scope = {**scope, "origins": ["chat"]}
runs = (await session.execute(_scope(select(Run.id, Run.origin), **chat_scope))).all()
run_ids = [rid for rid, _ in runs]
conversation: dict[str, str] = {rid: str((origin or {}).get("conversation_id")) for rid, origin in runs
if isinstance(origin, dict) and origin.get("conversation_id")}
if run_ids:
for rid, cid in (await session.execute(select(ConversationTurn.run_id, ConversationTurn.conversation_id)
.where(ConversationTurn.run_id.in_(run_ids)))).all():
conversation.setdefault(rid, cid)
snaps = (await session.execute(select(Artifact.id, Artifact.run_id).where(
Artifact.kind == QUERY_SNAPSHOT_KIND, Artifact.run_id.in_(run_ids)))).all() if run_ids else []
kinds = {name: kind for name, kind in (await session.execute(select(DataSource.name, DataSource.kind))).all()}
shaped = await asyncio.to_thread(lambda: [(rid, _shape_of(aid, kinds)) for aid, rid in snaps])
groups: dict[tuple[str, str], tuple[set[str], set[str]]] = {}
for rid, shape in shaped:
if shape is None:
continue
found_runs, convs = groups.setdefault(shape, (set(), set()))
found_runs.add(rid)
convs.add(conversation.get(rid) or f"run:{rid}")
repeated = [shape for shape, (rs, cs) in groups.items() if len(rs) >= REPEAT_RUNS and len(cs) >= REPEAT_CONVERSATIONS]
with_queries = {rid for rid, shape in shaped if shape is not None}
in_repeated = {rid for shape in repeated for rid in groups[shape][0]}
questions = await recent_questions(session, principal, limit=QUESTION_LIMIT)
if scope.get("since") is not None:
questions = [q for q in questions if q.at is None or q.at >= scope["since"]]
if scope.get("until") is not None:
questions = [q for q in questions if q.at is None or q.at < scope["until"]]
clusters = await cluster_questions(questions) if questions else []
clustered = sum(c.count for c in clusters if c.count >= REPEAT_RUNS)
return {
"chat_runs": len(run_ids), "runs_with_queries": len(with_queries), "shapes": len(groups),
"repeated_shapes": len(repeated), "runs_in_repeated_shapes": len(in_repeated),
"repeated_share": _ratio(len(in_repeated), len(with_queries)),
"threshold": {"runs": REPEAT_RUNS, "conversations": REPEAT_CONVERSATIONS},
"questions": len(questions), "clusters": len(clusters), "questions_in_repeated_clusters": clustered,
"cluster_share": _ratio(clustered, len(questions)), "embedder": embedder(),
"certify": await _certify(session, **scope),
}
async def _certify(session: Any, **scope: Any) -> dict[str, Any]:
"""认证队列的转化:固化成口径草稿几份、其中发布了几份、点了「不是口径」几项(按固化、点选的时间筛窗口)。"""
from app.db.models import AuditEvent, Workflow
from app.ledger.certify import DISMISS_ACTION
since, until = scope.get("since"), scope.get("until")
frozen = published = 0
for created, origin, current in (await session.execute(
select(Workflow.created_at, Workflow.origin, Workflow.published_version)
.where(Workflow.origin.is_not(None)))).all():
if not isinstance(origin, dict) or not origin.get("freeze"):
continue
if (since is not None and created is not None and created < since) or \
(until is not None and created is not None and created >= until):
continue
frozen += 1
published += current is not None
stmt = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == DISMISS_ACTION)
if since is not None:
stmt = stmt.where(AuditEvent.at >= since)
if until is not None:
stmt = stmt.where(AuditEvent.at < until)
dismissed = int(await session.scalar(stmt) or 0)
return {"available": True, "frozen": frozen, "published": published, "dismissed": dismissed,
"publish_rate": _ratio(published, frozen)}
async def _e4(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dict[str, Any]:
from app.db.models import Run
from app.engine.origin import counted_runs
proven: Counter[str] = Counter()
for content in contents:
keys = {c.get("caliber") for q in content.get("queries") or [] for c in ledger_labels.candidates_of(q)
if c.get("status") == "proven" and c.get("caliber")}
for key in keys:
proven[str(key)] += 1
stmt = select(Run.workflow_id, Run.version, func.count()).where(
Run.run_class == "formal", Run.workflow_id.is_not(None), Run.version.is_not(None))
stmt = stmt.where(Run.id.in_(list(scope["run_ids"]))) if scope.get("run_ids") is not None \
else stmt.where(counted_runs())
if scope.get("since") is not None:
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
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)}
for key in sorted(set(proven) | set(formal))]
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
from app.db.models import Run, RunEvent
stmt = select(Run.id, Run.origin).where(Run.origin_kind == "recall")
if scope.get("run_ids") is not None:
stmt = stmt.where(Run.id.in_(list(scope["run_ids"])))
else:
if scope.get("since") is not None:
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
runs = [(rid, o) for rid, o in (await session.execute(stmt)).all()
if isinstance(o, dict) and not o.get("probe") and o.get("workflow_id")]
settled: dict[str, dict[str, Any]] = {}
if runs:
rows = (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_([r for r, _ in runs]), RunEvent.type == EventType.LEDGER_RECALLED)
.order_by(RunEvent.seq))).all()
for rid, data in rows:
if isinstance(data, dict) and data.get("role") == "recall":
settled[rid] = data
by: dict[tuple[str, int], dict[str, int]] = {}
for rid, origin in runs:
key = (str(origin["workflow_id"]), int(origin.get("to_version") or 0))
item = by.setdefault(key, {"groups": 0, "settled": 0, "changed": 0, "failed": 0})
item["groups"] += 1
data = settled.get(rid)
if data is None:
continue
item["settled"] += 1
if data.get("outcome") == "failed":
item["failed"] += 1
elif data.get("changed"):
item["changed"] += 1
items = [{"caliber": ledger_labels.caliber_key(w, v), "workflow_id": w, "version": v, **c,
"changed_rate": _ratio(c["changed"], c["settled"] - c["failed"])}
for (w, v), c in sorted(by.items())]
return {"available": True, "publishes": items, "groups": sum(i["groups"] for i in items),
"changed": sum(i["changed"] for i in items)}
async def compute_stats(session: Any, *, since: datetime | None = None, until: datetime | None = None,
origins: Sequence[str] | None = None, run_ids: Collection[str] | None = None,
principal: Any = None) -> dict[str, Any]:
"""账本统计 E1–E4(见模块说明)。session 只读;principal 是看统计的人(问题聚类去掉他读不了的运行),不给按当前请求的身份。
返回 {window, algo, e1, e2, e3, e4};各块的键见 e1_of、_e2、_e3、_e4。比率在分母为 0 时为 None(不写成 0)。"""
from app.api.deps import current_principal
scope = {"since": since, "until": until, "origins": list(origins) if origins else None,
"run_ids": list(run_ids) if run_ids is not None else None}
contents = await _matches(session, **scope)
return {
"window": {"since": since.isoformat() if since else None, "until": until.isoformat() if until else None,
"origins": scope["origins"], "runs": scope["run_ids"]},
"algo": store.ALGO,
"e1": e1_of(contents),
"e2": await _e2(session, contents, **scope),
"e3": await _e3(session, principal=principal or current_principal(), **scope),
"e4": await _e4(session, contents, **scope),
}
__all__ = ["ASPECT_GROUP", "DIFF_GROUPS", "REPEAT_CONVERSATIONS", "REPEAT_RUNS", "STATUSES", "compute_stats", "e1_of"]