Files
AgentLab/backend/app/ledger/view.py
T
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

220 lines
12 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.
"""口径账本交给界面的样子(docs/design/next-round/02-口径账本.md 做法 3):GET /api/runs/{run_id}/ledger、证据片段接口的
ledger 键、报告导出的片段面板,三处读同一份比对工件、按同一套遮罩给。
遮罩按读的那一刻重滤(同证据面板,api/evidence._Masks):绑定的值是这次运行查到过的遮罩值时不给值(也就不能拿它代填出具),
差异条目里的 SQL 片段走 _Shown 同一套换法。工作流名按现在的取(工件里不存名字)。
界面层不放逻辑:标签、整题判断、能做的动作都由这里给。actions(第 3 波):整题等价时按那份口径正式出具(能不能、能不能
自动、不能的原因,ledger/issue.plan_issue,按看的这个人的权限判);有相关口径的查询看差多少(口径对账,ledger/reconcile)。
issued 是这次运行按口径出具过的正式运行,reconciled 是对过的账(各自的运行、状态,第 4 波起带上结果上的「说明有用 /
看不懂」:看的这个人给过的、各几个,ledger/feedback)。
recall(第 4 波,ledger/recall.answer_marks):这个答案身上的召回标记——「已按 vN 核对,不影响」(checked)、依据有变
(basis)、看的这个人手上说的是它的 recall 待办(tasks)。几种状态都带:没比对过、不比对的答案照样可能被标依据有变。
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, Workflow
from app.ledger import labels as ledger_labels
from app.ledger import match as ledger_match
from app.ledger import store
#: state:matched 比对过;pending 已排队按需比对;ineligible 这次运行不比对(reason 写为什么);unreadable 比对记录读不出
STATES = ("matched", "pending", "ineligible", "unreadable")
#: 成功结束、还没封存的运行,结束后这么多秒内答 pending(runner 落库之后马上封存,正常是几秒)。过了还没封存的是封存出了错、
#: 或者是封存之前的老数据:再答 pending,页面就一直转圈、等满两分钟(store/ledger.ts 的等待上限)再取还是 pending——照实说没封存
SEAL_GRACE_S = 120
class _Hide:
"""按这次运行查到过的遮罩值遮:hide(值) 这个值给不给,shown(文字) 文字里的遮罩值换成「已遮罩」。"""
def __init__(self, secrets: Any) -> None:
from app.api.evidence import _Shown
self._secrets = secrets
self._shown = _Shown(secrets)
def hide(self, value: Any) -> bool:
if self._secrets is None or value is None or isinstance(value, bool):
return False
return bool(self._secrets.hit(value) or self._secrets.weak_hit(str(value)))
def shown(self, value: Any) -> Any:
return self._shown.text(value)
async def _hider(sealed: Any) -> _Hide:
from app.api.evidence import _Masks
return _Hide(await _Masks(sealed).run_secrets())
async def _names(ids: set[str]) -> dict[str, str]:
if not ids:
return {}
async with SessionLocal() as session:
rows = await session.execute(select(Workflow.id, Workflow.name).where(Workflow.id.in_(sorted(ids))))
return {wid: name for wid, name in rows.all()}
def _workflow_ids(content: dict[str, Any]) -> set[str]:
return {str(c.get("workflow_id")) for c in content.get("calibers") or [] if c.get("workflow_id")}
def _whole_view(whole: dict[str, Any], hider: _Hide, names: dict[str, str]) -> dict[str, Any]:
out = {k: v for k, v in whole.items() if k != "binding"}
if whole.get("equivalent"):
out["binding"] = ledger_labels.binding_view(whole.get("binding"), hider.hide)
out["workflow_name"] = names.get(str(whole.get("workflow_id") or ""))
out["level_text"] = ledger_labels.LEVEL_TEXT.get(str(whole.get("level") or ""), "")
return out
def _candidate_view(cand: dict[str, Any], hider: _Hide, names: dict[str, str]) -> dict[str, Any]:
item = ledger_labels.related_view(cand, hide=hider.hide, shown=hider.shown, names=names)
item["status"] = cand.get("status")
if cand.get("how"):
item["how"] = cand["how"]
if cand.get("columns") is not None:
item["columns"] = cand["columns"]
if isinstance(cand.get("projection"), dict):
# 按引用的列投影之后比的(ledger/match 的 _projected、_cell):两边各留下了哪几列、去掉了几列(列名,不带值)
item["projection"] = cand["projection"]
return item
def _query_view(query: dict[str, Any], hider: _Hide, names: dict[str, str]) -> dict[str, Any]:
out = {k: query[k] for k in ("artifact", "node_id", "source", "tables", "status", "reason", "note") if k in query}
out["candidates"] = [_candidate_view(c, hider, names) for c in query.get("candidates") or []]
if isinstance(query.get("cells"), dict):
# 逐格比过的(algo 4):每一列的结论和候选,标签按它标;遮罩同上
out["cells"] = {column: {**{k: cell[k] for k in ("status", "reason") if k in cell},
"candidates": [_candidate_view(c, hider, names) for c in cell.get("candidates") or []]}
for column, cell in query["cells"].items() if isinstance(cell, dict)}
return out
def _labels_view(content: dict[str, Any], hider: _Hide, names: dict[str, str]) -> dict[str, Any]:
return {node: {"labels": {sid: ledger_labels.view_label(label, hide=hider.hide, shown=hider.shown, names=names)
for sid, label in (report.get("labels") or {}).items()}}
for node, report in (content.get("reports") or {}).items()}
def _stamp(recorded: store.Recorded) -> dict[str, Any]:
"""比对的来历:哪份工件、哪一版算法、谁触发的、什么时候追加的;事后补比的写明按现在的口径回看。"""
at = datetime.fromtimestamp(recorded.ts, timezone.utc).isoformat(timespec="seconds") \
if isinstance(recorded.ts, (int, float)) else None
return {"artifact": recorded.artifact, "algo": recorded.algo, "trigger": recorded.trigger, "retro": recorded.retro,
"matched_at": at, "post_seal": True, "note": ledger_labels.POST_SEAL_NOTE,
**({"retro_note": ledger_labels.CERTIFIED_NOTE if recorded.trigger == "certified"
else ledger_labels.RETRO_NOTE} if recorded.retro else {})}
def _just_finished(finished_at: datetime | None) -> bool:
"""运行是不是刚结束(SEAL_GRACE_S 以内)。没记结束时间的不算;库里读回 naive 的按 UTC 算(同 db/base.UTCDateTime)。"""
if finished_at is None:
return False
at = finished_at if finished_at.tzinfo is not None else finished_at.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - at).total_seconds() <= SEAL_GRACE_S
async def run_view(run_id: str) -> dict[str, Any]:
"""GET /api/runs/{run_id}/ledger 的答复:标签、整题判断、每条查询的比对、能做的动作。没比对过、能比的运行这时排上按需比对
(trigger=on_demand),答复 state=pending;不比的照实说为什么。"""
from app.api.coded import CodedHTTPException
from app.api.evidence import RUN_NOT_FOUND, _sealed
from app.api.deps import current_principal
from app.ledger.recall import answer_marks
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
principal = current_principal()
marks = await answer_marks(session, run_id, principal)
base: dict[str, Any] = {"run_id": run_id, "note": ledger_labels.POST_SEAL_NOTE, "recall": marks}
recorded = await store.latest(run_id)
if recorded is None:
why = ledger_match.ineligible(run)
if why == ledger_match.NOT_SEALED and run.status == "succeeded" \
and ledger_match.wanted(run.run_class, run.origin_kind) and _just_finished(run.finished_at):
# runner 先发 run.finished、落库,再封存、再排上比对(engine/runner._finalize):页面收到 run.finished 立刻来取,
# 这一下还没封存。这不是「不比对」,是封存完就比——照实答 pending,页面按退避接着取;这里不另排按需比对。
# 只限刚结束的那一会儿(SEAL_GRACE_S),早就结束、一直没封存的照实答 ineligible
return {**base, "state": "pending"}
sealed = await _sealed(run_id) if why is None else None
if sealed is not None and not sealed.queries:
why = ledger_match.NO_QUERIES
if why is not None:
return {**base, "state": "ineligible", "reason": why}
ledger_match.schedule(run_id, trigger="on_demand")
return {**base, "state": "pending"}
if recorded.content is None:
return {**base, "state": "unreadable", "reason": recorded.problem, **_stamp(recorded)}
from app.ledger import feedback as ledger_feedback
from app.ledger import issue as ledger_issue
from app.ledger import reconcile as ledger_reconcile
content = recorded.content
sealed = await _sealed(run_id)
hider = await _hider(sealed)
names = await _names(_workflow_ids(content))
plans = await ledger_issue.plans_for(run_id, content, principal=principal, hide=hider.hide,
artifact=recorded.artifact, retro=recorded.retro)
async with SessionLocal() as session:
issued = [ledger_issue.issued_view(r) for r in await ledger_issue.issued_runs(session, run_id)]
reconciled = [ledger_reconcile.reconciled_view(r) for r in await ledger_reconcile.reconciled_runs(session, run_id)]
# 对账结果上的「说明有用 / 看不懂」(ledger/feedback):看的这个人给过哪一个、各几个
votes = await ledger_feedback.feedback_of(session, [r["run_id"] for r in reconciled], principal)
reconciled = [{**r, "feedback": votes[r["run_id"]]} for r in reconciled]
return {
**base, "state": "matched", **_stamp(recorded),
"calibers": [{**{k: c.get(k) for k in ("caliber", "workflow_id", "version", "level", "graph_hash")},
"workflow_name": names.get(str(c.get("workflow_id") or "")),
"level_text": ledger_labels.LEVEL_TEXT.get(str(c.get("level") or ""), "")}
for c in content.get("calibers") or []],
"queries": [_query_view(q, hider, names) for q in content.get("queries") or []],
"reports": _labels_view(content, hider, names),
"primary": content.get("primary"),
"whole": _whole_view(content.get("whole") or {}, hider, names),
"actions": [*(p.action() for p in plans), *ledger_reconcile.actions_of(content, names, hider.hide)],
"issued": issued,
"reconciled": reconciled,
}
async def report_labels(sealed: Any, report_node: str) -> dict[str, Any] | None:
"""一份报告里每个数的标签(遮过的),给证据片段接口和报告导出:{labels: {片段 id: 标签}, 来历}。没比对过、比对记录读不出、
这份报告没有标签的为 None(片段接口就不加 ledger 键,老运行的返回一字不差)。"""
recorded = await store.latest(sealed.run_id)
if recorded is None or recorded.content is None:
return None
report = (recorded.content.get("reports") or {}).get(report_node)
if not isinstance(report, dict):
return None
hider = await _hider(sealed)
names = await _names(_workflow_ids(recorded.content))
return {"labels": {sid: ledger_labels.view_label(label, hide=hider.hide, shown=hider.shown, names=names)
for sid, label in (report.get("labels") or {}).items()}, **_stamp(recorded)}
async def segment_label(sealed: Any, report_node: str, segment_id: str) -> dict[str, Any] | None:
"""证据片段接口的 ledger 键:{label: 这个片段的标签, 比对的来历(artifact 是比对工件)};这个片段没有标签(不是数、没比对过)
为 None。"""
found = await report_labels(sealed, report_node)
if found is None or segment_id not in found["labels"]:
return None
return {"label": found["labels"][segment_id], **{k: v for k, v in found.items() if k != "labels"}}
__all__ = ["STATES", "report_labels", "run_view", "segment_label"]