mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:32:01 +00:00
77fc0efa10
为什么:口径升版是被迫处置的事件(02 做法 7、拍板 L6),已交付的答案以前没人追;目录事实被驳回、表结构变了也没有地方标。
- ledger/uses.py(新):反查旧版本被谁用过——比对工件里等价已证明的问数回答(含轮次、对话主人)、W 的正式运行(定时任务、
应用各取最近 4 期)、固定了它的工作流版本发起的正式运行、固定这一版的应用、导出审计(导出人也是主人);预告、召回、
版本用量共用这一份
- ledger/recall.py(新):计划按(旧版本、新版本、input_key)去重成组,从已有召回运行重算(幂等、不建任务表);每组一次
召回运行(origin_kind=recall,探索运行),派生图经 derive.recall_graph(新旧两版取数和口径卡带前缀、召回卡里算两版之差);
不能重算照实记原因(数据部分不能复用、新版没这张卡、入口参数不齐、图里有人工节点或写入);每次发布最多 recall_limit 组,
超出的手动接着算;收尾时按渲染的旧值新值判数变没变:变了开 recall 待办(开登录按主人各一条、关登录派给任何人)、推
ledger.recall,没变在原答案上记 ledger.recalled;待办「按新版重新出具」走 issue.check_target 同一套判法、「知道了」
- 目录事实被驳回或修改(审阅、人工编辑、保存提案、批量审阅之后):冻结目录里看得到这一项的探索运行记 ledger.basis_changed,
按新目录多出错误级 SQL 检查问题的才开待办;表结构变动消费 source_changes(游标、处理时改了目录的当场交过去),标依据的
表结构已变,受影响的口径试算一次,算不出来才通知发布人
- 发布钩子在 commit_publish 提交之后排队(自动的几样和调度循环同一个开关),GET /api/workflows/{id}/recall-preview 给发布
弹窗预告;GET/POST /api/ledger/recall/{wf}/{v}[/continue] 给版本历史看批次、接着算;版本用量加问数回答、导出、召回
- ledger.recall 打开订阅(全局、个人渠道),同一渠道只推一次,消息不放数;新事件 ledger.recalled、ledger.basis_changed
登记为封存后追加的事件,可验证报告离线核对页认得;E4 每次发布召回了几组、数变了的比例
- runner 收尾时召回运行在后台收尾,启动时接着做没发完的批次
366 lines
20 KiB
Python
366 lines
20 KiB
Python
"""口径账本自身给出的统计 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 波)记成对账运行上的封存后事件,这之前照实写没有数据。
|
||
- 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")
|
||
#: 没有数据时的说法(这一块的功能还没做)
|
||
NOT_YET_RECONCILE = "对账面板的反馈在第 4 波接上,目前没有数据"
|
||
#: 聚类最多取几个问题(同治理接口的上限)
|
||
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"])
|
||
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)]
|
||
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": {"available": False, "note": NOT_YET_RECONCILE}}
|
||
|
||
|
||
#: 查询快照 → (数据源, 形状)。工件按内容寻址、不会变,记下来不会过期
|
||
_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"]
|