Files
AgentLab/backend/app/ledger/stats.py
T
ThearchyHelios b3dcddb52e 召回复核修正(后端):人工节点不再挡召回、读不出召回卡记失败、旧版不再接着算、团队渠道一次召回只推一条、目录核对按数据源合并排队;账本接口带上召回标记,对账结果可以反馈
- W4R-01:召回只查新版图里会不会写外部系统(apps/backfill 抽出 graph_external_write,graph_side_effect 复用它)。召回图只有两版
  的取数和口径卡,图里别处的人工节点、按流程使用的问题碰不到它;口径卡上游的人工节点 derive 本来就认作数据部分不能复用。
  「按新版重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝。原来带审批的口径升版后一组都不召回,L6 对它们失效。
- W4R-09:收尾时召回卡读不出、或缺了某个比较的旧值 / 新值,照实记重算失败(读不出召回卡),原答案一个不动。原来两边都是空,
  按渲染比被当成「没变」,给每个原答案永久盖上「已核对,不影响」,收过尾之后再也不核对。
- W4R-12:这一版已经不是当前发布版(之后又发布了更新的版本)时不再发起召回、不给接着算,批次里写 continue_reason,接着算的
  接口 409 带同一句。同启动时接着做只认当前发布版:新版本的召回覆盖更早的所有版本,再按旧版开「口径升到 vN」的待办只是添乱。
- W4R-13:开登录时一组答案几个主人就几条待办,全局渠道却按这一批(recall.batch_of_key:同一次召回、同一个来由)只在最早开的
  那条上推一次,答案、待办按这一批汇总,几条的链到待办箱;别的几条连 task.opened 也不再推全局渠道。个人渠道照旧每人一条。
- W4R-11:目录审阅之后的核对按数据源排队、批量审阅的几张表合成一遍(catalog_pass,同一数据源一把锁);查询快照按数据源在库里
  筛、按运行结束时间分页、每张表凑够 BASIS_MAX_RUNS 次运行就停;冻结的表结构快照这一遍里按工件 id 只读一次。原来批量审阅
  几十张表就同时起几十个后台任务,每个都全表扫查询快照、反复读同一份几 MB 的表结构快照。
- W4R-03(本包那一半):GET /api/runs/{id}/ledger 加 recall:checked(已按 vN 核对,不影响)、basis(依据有变:哪一种、几项、
  新出现几条错误级问题)、tasks(看的这个人手上说的是这个答案、还没处理的 recall 待办)。不放数、不放表名。几种状态都带。
- W4R-10(本包那一半):新事件 ledger.feedback(对账运行上的封存后事件),POST /api/ledger/answers/{run}/reconcile/{rid}/feedback
  {verdict: useful | unclear} 记反馈,同一个人改主意只算最后一次(app/ledger/feedback.py);账本接口的 reconciled 每一项带
  feedback {mine, useful, unclear};统计 E2 的 feedback 数到它,去掉「第 4 波接上」的占位说法。离线核对页、封存后事件摘要
  都认得这个事件。

测试:test_ledger_recall 新增七个用例、改了人工节点那一个(召回照算、重新出具照实拒绝);新增 test_ledger_feedback;
test_ledger_api 的 E2 断言跟上。每个修复都先写失败的测试、去掉修复回跑确认抓得住。后端全量 14237 通过。
2026-10-16 22:16:58 +08:00

367 lines
20 KiB
Python
Raw 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),有候选口径
的查询里各自的比率;证明等价的绑定是在哪一级配上的;没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型
分布(time / filter / aggregate / join / other / parse,每份口径的差异每类只计一次);逐个数的标签各几个、整题等价几次。
- 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")
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()
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
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
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 []}
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"},
"proven_via": dict(via), "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 []:
for cand in query.get("candidates") or []:
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 q.get("candidates") or []
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"])
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()}
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 _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"]