Files
AgentLab/backend/app/ledger/view.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

329 lines
18 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")
#: 普通使用者看的口径账本里**不出去**的键(递归过一遍整份视图):口径的内部身份。人读的那一套——口径名(caliber、
#: workflow_name)、级别叫法、指标与标签、每条查询的结果与证据关系、动作的说法、出具/对账的运行——都原样留着,只是认不出
#: 「是哪一份工作流的哪一版」。builder 及以上拿到的是原件(run_view 只在普通使用者这一档过一遍)。
USER_HIDDEN_KEYS = ("workflow_id", "workflow_version", "version", "graph_hash", "manifest", "manifest_hash",
"publication", "closure_hash", "selection_hash", "execution_hash", "identity")
#: 只在**口径标签/相关口径**那几处去掉的键:口径卡的节点 id(「是这张卡的哪个节点」是内部的事)。查询本身的 node_id
#: 是这次运行图上的节点,证据关系靠它,留着。
USER_HIDDEN_LABEL_KEYS = ("node_id",)
#: 成功结束、还没封存的运行,结束后这么多秒内答 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
def _scrub(value: Any) -> Any:
"""递归过一遍:内部身份的键去掉(别的键、值、结构一个不动)。"""
if isinstance(value, dict):
return {k: _scrub(v) for k, v in value.items() if k not in USER_HIDDEN_KEYS}
if isinstance(value, list):
return [_scrub(v) for v in value]
return value
def _without(item: Any, keys: tuple[str, ...]) -> Any:
return {k: v for k, v in item.items() if k not in keys} if isinstance(item, dict) else item
def _name_in_place(item: dict[str, Any]) -> None:
"""就地:口径那一栏的 `caliber` 是**内部键**(`工作流 id@版本`,ledger/labels.caliber_key),换成**人读的名字**。
名字取不到(工作流删了)时去掉这一栏——宁可少一栏,也不把 id 交出去。别的一切原样。"""
key = item.get("caliber")
if isinstance(key, str) and "@" in key:
name = item.get("workflow_name")
if name:
item["caliber"] = str(name)
else:
item.pop("caliber", None)
def _named(item: Any) -> Any:
if not isinstance(item, dict):
return item
out = dict(item)
_name_in_place(out)
return out
def _action_view(item: Any) -> Any:
"""一项动作:能不能做、为什么、级别叫法与身份都按同一套投影;**只有确实给这个人做的那一项**留着口径身份。
出具与对账这两下都要带着 `workflow_id` + `version` 去发请求(`POST /api/ledger/answers/{run_id}/issue|reconcile`),
而这两项只有在这个人**确实有权**(`available`:应用授权、比对等价、绑定没被遮)时才会给出来——服务端在收到请求时还会
再核一遍。被拒的那些项只是说明,不是操作凭据,身份不带出去(不然就成了拿别人的口径 id 探路)。"""
if not isinstance(item, dict):
return item
out = _named(_scrub(item))
if item.get("available"):
for key in ("workflow_id", "version"):
if key in item:
out[key] = item[key]
return out
def label_user_view(label: dict[str, Any]) -> dict[str, Any]:
"""一个标签交给普通使用者的那一份:口径身份不出去(卡节点 id、工作流 id、版本、哈希),指标与结论一个字不动。
账本视图、证据片段的「口径」一步、报告导出读的是同一份标签(ledger/labels.view_label),三处按同一条规则投影。"""
out = _named(_scrub(label))
out.pop("node_id", None)
out["related"] = [_named(_without(item, USER_HIDDEN_LABEL_KEYS)) for item in out.get("related") or []]
return out
def caliber_user_view(view: dict[str, Any]) -> dict[str, Any]:
"""普通使用者看的口径账本(`/api/runs/{id}/ledger`):口径身份不出去,人读的部分一个字不动。
去掉的:工作流 id / 版本号 / 清单引用 / 各种哈希(递归地)、口径标签里的卡节点 id、口径那一栏里的内部键(换成名字);
只有「确实给这个人做」的动作(能出具、能对账)另带它要用的那两个身份。留着的:口径名(caliber、workflow_name)、
级别叫法、指标与标签、每条查询的结果、候选与证据关系、动作的说法(能不能出具、为什么)、出具与对账的运行和结论——
界面按这些画,一样不缺。builder 及以上不看这一份(run_view 里按 restricted() 分)。"""
out = _scrub(view)
out["calibers"] = [_named(c) for c in out.get("calibers") or []]
out["whole"] = _named(_without(out.get("whole"), USER_HIDDEN_LABEL_KEYS))
out["actions"] = [_action_view(action) for action in view.get("actions") or []]
for query in out.get("queries") or []:
if not isinstance(query, dict):
continue
query["candidates"] = [_named(_without(c, USER_HIDDEN_LABEL_KEYS)) for c in query.get("candidates") or []]
for cell in (query.get("cells") or {}).values():
if isinstance(cell, dict):
cell["candidates"] = [_named(_without(c, USER_HIDDEN_LABEL_KEYS))
for c in cell.get("candidates") or []]
for report in (out.get("reports") or {}).values():
if not isinstance(report, dict):
continue
report["labels"] = {sid: label_user_view(label) for sid, label in (report.get("labels") or {}).items()
if isinstance(label, dict)}
return out
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.deps import restricted
from app.api.evidence import RUN_NOT_FOUND, _sealed
from app.api.deps import current_principal
from app.ledger.recall import answer_marks
ordinary = restricted()
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]
# 对账这一项能不能做:同一份资产授权判定(reconcile.actions_of 里按这个身份给 available;无权的那些不带身份)
reconcile_actions = await ledger_reconcile.actions_of(session, content, names, hider.hide, actor=principal)
view = {
**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), *reconcile_actions],
"issued": issued,
"reconciled": reconciled,
}
# 普通使用者那一档另过一遍投影(口径身份不出去,人读的部分不动);builder 及以上拿原件
return caliber_user_view(view) if ordinary else view
async def report_labels(sealed: Any, report_node: str) -> dict[str, Any] | None:
"""一份报告里每个数的标签(遮过的),给证据片段接口和报告导出:{labels: {片段 id: 标签}, 来历}。没比对过、比对记录读不出、
这份报告没有标签的为 None(片段接口就不加 ledger 键,老运行的返回一字不差)。
普通使用者拿到的是投影过的那一份(口径身份不出去);builder 及以上是原件。"""
from app.api.deps import restricted
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))
labels = {sid: ledger_labels.view_label(label, hide=hider.hide, shown=hider.shown, names=names)
for sid, label in (report.get("labels") or {}).items()}
if restricted():
labels = {sid: label_user_view(label) for sid, label in labels.items()}
return {"labels": labels, **_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"]