Files
AgentLab/backend/app/ledger/feedback.py
T
ThearchyHelios b3dcddb52e 召回复核修正(后端):人工节点不再挡召回、读不出召回卡记失败、旧版不再接着算、团队渠道一次召回只推一条、目录核对按数据源合并排队;账本接口带上召回标记,对账结果可以反馈
- W4R-01:召回只查新版图里会不会写外部系统(apps/backfill 抽出 graph_external_write,graph_side_effect 复用它)。召回图只有两版
  的取数和口径卡,图里别处的人工节点、按流程使用的问题碰不到它;口径卡上游的人工节点 derive 本来就认作数据部分不能复用。
  「按新版重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝。原来带审批的口径升版后一组都不召回,L6 对它们失效。
- W4R-09:收尾时召回卡读不出、或缺了某个比较的旧值 / 新值,照实记重算失败(读不出召回卡),原答案一个不动。原来两边都是空,
  按渲染比被当成「没变」,给每个原答案永久盖上「已核对,不影响」,收过尾之后再也不核对。
- W4R-12:这一版已经不是当前发布版(之后又发布了更新的版本)时不再发起召回、不给接着算,批次里写 continue_reason,接着算的
  接口 409 带同一句。同启动时接着做只认当前发布版:新版本的召回覆盖更早的所有版本,再按旧版开「口径升到 vN」的待办只是添乱。
- W4R-13:开登录时一组答案几个主人就几条待办,全局渠道却按这一批(recall.batch_of_key:同一次召回、同一个来由)只在最早开的
  那条上推一次,答案、待办按这一批汇总,几条的链到待办箱;别的几条连 task.opened 也不再推全局渠道。个人渠道照旧每人一条。
- W4R-11:目录审阅之后的核对按数据源排队、批量审阅的几张表合成一遍(catalog_pass,同一数据源一把锁);查询快照按数据源在库里
  筛、按运行结束时间分页、每张表凑够 BASIS_MAX_RUNS 次运行就停;冻结的表结构快照这一遍里按工件 id 只读一次。原来批量审阅
  几十张表就同时起几十个后台任务,每个都全表扫查询快照、反复读同一份几 MB 的表结构快照。
- W4R-03(本包那一半):GET /api/runs/{id}/ledger 加 recall:checked(已按 vN 核对,不影响)、basis(依据有变:哪一种、几项、
  新出现几条错误级问题)、tasks(看的这个人手上说的是这个答案、还没处理的 recall 待办)。不放数、不放表名。几种状态都带。
- W4R-10(本包那一半):新事件 ledger.feedback(对账运行上的封存后事件),POST /api/ledger/answers/{run}/reconcile/{rid}/feedback
  {verdict: useful | unclear} 记反馈,同一个人改主意只算最后一次(app/ledger/feedback.py);账本接口的 reconciled 每一项带
  feedback {mine, useful, unclear};统计 E2 的 feedback 数到它,去掉「第 4 波接上」的占位说法。离线核对页、封存后事件摘要
  都认得这个事件。

测试:test_ledger_recall 新增七个用例、改了人工节点那一个(召回照算、重新出具照实拒绝);新增 test_ledger_feedback;
test_ledger_api 的 E2 断言跟上。每个修复都先写失败的测试、去掉修复回跑确认抓得住。后端全量 14237 通过。
2026-10-16 22:16:58 +08:00

109 lines
5.7 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 做法 10 的 E2)。
对账(ledger/reconcile)把这次的答案和一份相关口径在同一份数据上各算一次,结果卡片上写差在哪、差多少。看的人觉得这段
说明有没有帮他看懂,点一下「说明有用」或「看不懂」——这是 E2 里唯一要人给的数,衡量差异说明写得好不好。
- 记在哪:对账运行上的封存后事件 ledger.feedback({verdict, by, verified, source_run},engine/run_append,post_seal)。
运行本身就是记录,不另建反馈表(同统计「全部现算、不另存」);批注不是证据,封存核对照旧一致。
- 谁能给:能读这个答案的人(同发起对账,ledger/reconcile:使用者对自己的答案也能对账);对账要是这个答案的(origin.source_run),
而且已经跑完封存。
- 怎么数:同一个人(开登录时按用户名;关登录时署名是自报的,按署名、没署名的算同一个)改主意再记一条,只算他最后一次。
账本接口 reconciled 每一项带上 {mine, useful, unclear}(feedback_of),统计 E2 数整个窗口(counts)。
"""
from __future__ import annotations
from collections.abc import Collection, Iterable
from typing import Any
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
#: 两种反馈
VERDICTS = ("useful", "unclear")
#: 被拒的机器码
REFUSED = "ledger_feedback_refused"
NOT_FOUND = "这次对账不存在,或者不是这个答案的对账"
NO_ACCESS = "你没有权限查看这个答案"
NOT_SEALED = "对账还没运行完,等结果出来再评说明有没有用"
BAD_VERDICT = "反馈只有两种:说明有用、看不懂"
class FeedbackRefused(Exception):
def __init__(self, status_code: int, message: str, code: str = REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
def _who(principal: Any) -> str:
return str(getattr(principal, "username", None) or "")
async def give(answer_id: str, reconcile_id: str, verdict: str, *, principal: Any) -> dict[str, Any]:
"""记一条反馈(见模块说明),交回这次对账现在的反馈 {mine, useful, unclear}。不能记的抛 FeedbackRefused。"""
from app.api.deps import can_read_run
from app.engine.run_append import append_events
from app.ledger.reconcile import ORIGIN as RECONCILE
if verdict not in VERDICTS:
raise FeedbackRefused(422, BAD_VERDICT)
async with SessionLocal() as session:
answer = await session.get(Run, answer_id)
rec = await session.get(Run, reconcile_id)
origin = rec.origin if rec is not None and isinstance(rec.origin, dict) else {}
if answer is None or rec is None or rec.origin_kind != RECONCILE or origin.get("source_run") != answer_id:
raise FeedbackRefused(404, NOT_FOUND)
if getattr(principal, "mode", "off") != "off" and not await can_read_run(principal, answer):
raise FeedbackRefused(403, NO_ACCESS)
data = {"verdict": verdict, "by": _who(principal) or None, "verified": bool(getattr(principal, "verified", False)),
"source_run": answer_id}
if not await append_events(reconcile_id, [(EventType.LEDGER_FEEDBACK, None, data)], require="sealed"):
raise FeedbackRefused(409, NOT_SEALED)
async with SessionLocal() as session:
return (await feedback_of(session, [reconcile_id], principal))[reconcile_id]
async def _latest(session: Any, run_ids: Collection[str]) -> dict[str, dict[str, str]]:
"""{对账运行: {谁: 他最后一次给的}}。"""
out: dict[str, dict[str, str]] = {}
if not run_ids:
return out
rows = (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_(list(run_ids)), RunEvent.type == EventType.LEDGER_FEEDBACK).order_by(RunEvent.seq))).all()
for run_id, data in rows:
if isinstance(data, dict) and data.get("verdict") in VERDICTS:
out.setdefault(run_id, {})[str(data.get("by") or "")] = str(data["verdict"])
return out
async def feedback_of(session: Any, run_ids: Iterable[str], principal: Any) -> dict[str, dict[str, Any]]:
"""每次对账的反馈:{对账运行: {mine: 看的这个人最后一次给的(没给过为 None), useful: 几个人, unclear: 几个人}}。"""
ids = list(dict.fromkeys(run_ids))
latest = await _latest(session, ids)
me = _who(principal)
out: dict[str, dict[str, Any]] = {}
for run_id in ids:
votes = latest.get(run_id, {})
out[run_id] = {"mine": votes.get(me), "useful": sum(v == "useful" for v in votes.values()),
"unclear": sum(v == "unclear" for v in votes.values())}
return out
async def counts(session: Any, run_ids: Collection[str]) -> dict[str, Any]:
"""统计 E2 的反馈(ledger/stats._e2):这些对账运行上,说明有用、看不懂各几个(每人只算最后一次),给过反馈的对账几次,
有用的比率(分母为 0 时为 None)。"""
latest = await _latest(session, run_ids)
useful = sum(v == "useful" for votes in latest.values() for v in votes.values())
unclear = sum(v == "unclear" for votes in latest.values() for v in votes.values())
total = useful + unclear
return {"available": True, "useful": useful, "unclear": unclear, "runs": sum(1 for v in latest.values() if v),
"useful_rate": round(useful / total, 4) if total else None}
__all__ = ["BAD_VERDICT", "NOT_FOUND", "NOT_SEALED", "REFUSED", "VERDICTS", "FeedbackRefused", "counts", "feedback_of",
"give"]