Files
AgentLab/backend/app/ledger/view.py
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

327 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.engine.evidence_reader import ShownText
self._secrets = secrets
self._shown = ShownText(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.engine.evidence_reader import open_masks
return _Hide(await open_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 current_principal, restricted
from app.engine.evidence_reader import RUN_NOT_FOUND, load_sealed as _sealed
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"]