mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
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 通过。
329 lines
18 KiB
Python
329 lines
18 KiB
Python
"""口径账本交给界面的样子(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"]
|