Files
AgentLab/backend/tests/test_ledger_feedback.py
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

120 lines
5.9 KiB
Python
Raw Permalink 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.
"""对账结果的「说明有用 / 看不懂」(next-round 02 做法 10 的 E2;app/ledger/feedback.py)。
真的跑:虚构店铺库里的问数据答案(算上了退款)和受管口径「周销售」(只算已支付)对一次账,再在结果上给反馈:
- 反馈记成对账运行上的封存后事件 ledger.feedback(批注不是证据,不进封存范围);同一个人改主意只算最后一次;
- 账本接口 reconciled 每一项带上反馈(看的这个人给过的、各几个),界面照着画选中的那一个;
- 统计 E2 数到它(不再写「第 4 波接上」);
- 不认的反馈、不是这个答案的对账、还没跑完的对账照实拒绝。
数据都是编的。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import RunEvent
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
from app.main import app
from tests import ledger_samples as S
CHECKED = {"start": "2026-09-01", "end": "2026-09-08"}
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
path = tmp_path / "shop.db"
await S.fresh_source(path)
yield path
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def reconciled(client, monkeypatch) -> tuple[str, str]:
"""问一次(算上了退款)、按核对过的参数和「周销售」对一次账,等它跑完。返回 (答案, 对账运行)。"""
wf = await S.publish()
row = await S.explore(monkeypatch, S.WITH_REFUNDS)
body = (await client.get(f"/api/runs/{row.id}/ledger")).json()
[action] = [a for a in body["actions"] if a["kind"] == "reconcile"]
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile",
json={"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED,
"confirm": True})
assert res.status_code == 201, res.text
done = await S.wait(res.json()["run_id"])
assert done.status == "succeeded", done.error
return row.id, done.id
async def test_feedback_is_a_post_seal_note_counted_by_e2(client, monkeypatch):
answer, rec = await reconciled(client, monkeypatch)
before = (await client.get("/api/ledger/stats")).json()["e2"]["feedback"]
assert before["available"] is True and "第 4 波" not in str(before)
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 200, res.text
assert res.json() == {"run_id": rec, "feedback": {"mine": "useful", "useful": 1, "unclear": 0}}
async with SessionLocal() as session:
[data] = (await session.execute(select(RunEvent.data).where(
RunEvent.run_id == rec, RunEvent.type == EventType.LEDGER_FEEDBACK))).scalars()
assert data["verdict"] == "useful" and data["post_seal"] is True and data["source_run"] == answer
# 改主意:同一个人只算最后一次
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "unclear"})
assert res.json()["feedback"] == {"mine": "unclear", "useful": 0, "unclear": 1}
[item] = (await client.get(f"/api/runs/{answer}/ledger")).json()["reconciled"]
assert item["run_id"] == rec and item["feedback"] == {"mine": "unclear", "useful": 0, "unclear": 1}
after = (await client.get("/api/ledger/stats")).json()["e2"]["feedback"]
assert after["useful"] - before["useful"] == 0 and after["unclear"] - before["unclear"] == 1
assert after["runs"] - before["runs"] == 1 and after["available"] is True
async def test_feedback_refusals(client, monkeypatch):
answer, rec = await reconciled(client, monkeypatch)
bad = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "great"})
assert bad.status_code == 422
other = await S.explore(monkeypatch, S.ELSEWHERE, text="门店 [[v:Q1.r0.n]] 家。")
res = await client.post(f"/api/ledger/answers/{other.id}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 404 and res.json()["code"] == "ledger_feedback_refused", "不是这个答案的对账"
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{answer}/feedback", json={"verdict": "useful"})
assert res.status_code == 404, "答案自己不是对账运行"
# 还没封存的对账(正在跑、刚发起):没有结果可评
from app.db.models import Run
async with SessionLocal() as session:
row = await session.get(Run, rec)
sealed = row.manifest_hash
row.manifest_hash = None
await session.commit()
try:
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 409 and "还没运行完" in res.json()["detail"], res.text
finally:
async with SessionLocal() as session:
(await session.get(Run, rec)).manifest_hash = sealed
await session.commit()
def test_offline_page_and_summary_know_the_feedback_event():
import re
from pathlib import Path
from app.bundle.build import _summary
script = (Path(__file__).resolve().parents[1] / "app" / "bundle" / "verifier.js").read_text(encoding="utf-8")
block = re.search(r"const EVENT_LABEL = \{(.*?)\n \}", script, re.S)
labels = dict(re.findall(r"'([a-z_.]+)': '([^']+)'", block.group(1)))
assert labels["ledger.feedback"] == "对账说明的反馈"
assert _summary("ledger.feedback", {"verdict": "useful"}) == "反馈:说明有用"
assert _summary("ledger.feedback", {"verdict": "unclear"}) == "反馈:看不懂"