Files
AgentLab/backend/tests/test_capability_review.py
T

194 lines
11 KiB
Python

"""能力字段语义的审阅(D-i):写进来的规矩、待审阅的队列、批量审阅、权限。
- 字段语义沿用数据目录「项」:人工新填、改过的记为人工填写、已确认;值没动的保持原状态;客户端冒充不了有确证的来源;
明确标成推断(模型起草、命名推断)的新项按推断收下;取值不合规的报错用「返回字段」的说法;
- 队列只列推断的项,按用到它的运行次数、是不是写入能力、项的风险、来源排;来源删掉了的能力不列;
- 批量审阅逐个能力按 version 乐观锁、一次写入、version 加一、写审计;冲突、位置不对、能力不存在只记在那几条上;
- 搭建者能看队列,审阅只给管理员。
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import delete, select
from app.data import capability_review as review
from app.data.capability_review import BatchItem, FieldsInvalid, normalize_fields
from app.db.base import SessionLocal
from app.db.models import AuditEvent, Capability, Run
from tests.fakes import auth_env
# ---------------------------------------------------------------- 写进来
def test_human_edits_are_confirmed_and_shorthand_is_accepted():
out = normalize_fields({}, {"/amount": {"label": "金额", "unit": {"value": "元"}}}, at="t")
assert out["/amount"]["label"] == {"value": "金额", "source": "human", "status": "confirmed", "updated_at": "t"}
assert out["/amount"]["unit"]["status"] == "confirmed"
def test_unchanged_items_keep_their_state_and_sources_cannot_be_faked():
existing = {"/amount": {"label": {"value": "金额", "source": "llm", "status": "proposed", "updated_at": "a"}}}
kept = normalize_fields(existing, existing, at="t")
assert kept == existing, "值没动、状态没改:原样留着"
faked = normalize_fields({}, {"/id": {"label": {"value": "单号", "source": "fk", "status": "verified"}}}, at="t")
assert faked["/id"]["label"]["source"] == "human" and faked["/id"]["label"]["status"] == "confirmed", \
"客户端冒充不了有确证的来源"
proposed = normalize_fields({}, {"/id": {"label": {"value": "单号", "source": "llm", "status": "proposed"}}},
at="t")
assert proposed["/id"]["label"] == {"value": "单号", "source": "llm", "status": "proposed", "updated_at": "t"}
confirmed = normalize_fields(existing, {"/amount": {"label": {**existing["/amount"]["label"],
"status": "confirmed"}}}, at="t")
assert confirmed["/amount"]["label"]["status"] == "confirmed", "等同单项审阅"
assert normalize_fields(existing, {}, at="t") == {}, "提交里没有的项删掉"
@pytest.mark.parametrize("submitted, said", [
({"amount": {"label": "金额"}}, "以 / 开头"),
({"/amount": "金额"}, "按项分开的对象"),
({"/amount": {"measure": "很多"}}, "返回字段 /amount 的度量类型"),
({"/amount": {"colour": "红"}}, "返回字段 /amount 含有不认识的字段「colour」"),
({"/avg": {"meaning": "客单价=金额/人数"}}, "返回字段 /avg 的含义看起来是计算公式"),
([], "按 JSON 指针分项"),
])
def test_bad_fields_are_refused_in_capability_words(submitted, said):
with pytest.raises(FieldsInvalid) as e:
normalize_fields({}, submitted)
assert said in str(e.value) and "列 " not in str(e.value) and "数据目录" not in str(e.value), str(e.value)
# ---------------------------------------------------------------- 队列与批量审阅
def _proposed(value, source="llm"):
return {"value": value, "source": source, "status": "proposed"}
@pytest.fixture
async def caps():
"""一项写入能力(被 2 次运行用到)、一项只读能力(没用过)、一项来源已删掉的能力,各带几项推断。"""
tag = uuid.uuid4().hex[:6]
names = {"pay": f"erp_pay_{tag}", "orders": f"erp_orders_{tag}", "gone": f"gone_{tag}"}
async with SessionLocal() as s:
s.add_all([
Capability(name=names["pay"], system="erp", title="付款", effect_system="write", fields={
"/amount": {"label": _proposed("金额"), "measure": _proposed("flow", "name")},
"/card": {"label": {"value": "卡号", "source": "human", "status": "confirmed"}}}),
Capability(name=names["orders"], system="erp", title="订单", effect_system="read", fields={
"/status": {"codes": _proposed({"1": "已付"}, "comment"), "label": _proposed("状态")}}),
Capability(name=names["gone"], system="erp", effect_system="read", missing=True,
fields={"/x": {"label": _proposed("某字段")}}),
])
runs = [Run(workflow_name="用过", options={"capabilities": {names["pay"]: {"version": 1}}}) for _ in range(2)]
s.add_all(runs)
await s.commit()
run_ids = [r.id for r in runs]
try:
yield names
finally:
async with SessionLocal() as s:
await s.execute(delete(Capability).where(Capability.name.in_(list(names.values()))))
await s.execute(delete(Run).where(Run.id.in_(run_ids)))
await s.execute(delete(AuditEvent).where(AuditEvent.subject_id.in_(list(names.values()))))
await s.commit()
async def test_the_queue_ranks_by_runs_effect_risk_and_source(caps):
async with SessionLocal() as s:
counts = await review.run_counts(s)
queue = await review.review_queue(s, limit=200)
assert counts[caps["pay"]] == 2
mine = [i for i in queue["items"] if i["capability"] in caps.values()]
assert [(i["capability"], i["pointer"], i["field"]) for i in mine] == [
(caps["pay"], "/amount", "measure"), # 用过 2 次 × 写入 × 度量类型(影响能不能加总)
(caps["pay"], "/amount", "label"),
(caps["orders"], "/status", "codes"),
(caps["orders"], "/status", "label"),
], "已确认的、来源已删掉的能力不列"
top = mine[0]
assert top["field_label"] == "返回字段 /amount 的度量类型" and top["display"] == "可累加"
assert top["runs"] == 2 and top["effect"] == "write" and top["version"] == 1
assert top["reasons"][0] == "被 2 次运行用到" and "写入能力" in top["reasons"][1] and top["reasons"][-1] == \
"命名推断,未确认"
async with SessionLocal() as s:
facts = await review.review_queue(s, kind="facts", capability=caps["pay"])
names = await review.review_queue(s, kind="names", capability=caps["orders"])
assert [i["field"] for i in facts["items"]] == ["measure"] and facts["total"] == 1
assert {i["field"] for i in names["items"]} == {"codes", "label"}
async def test_batch_review_locks_per_capability_and_audits(caps):
pay, orders = caps["pay"], caps["orders"]
items = [
BatchItem(pay, "/amount", "label", "confirm", 1),
BatchItem(pay, "/amount", "measure", "reject", 1),
BatchItem(pay, "/nope", "label", "confirm", 1),
BatchItem(orders, "/status", "label", "confirm", 7), # 版本对不上
BatchItem(caps["gone"], "/x", "label", "confirm", 1),
BatchItem("no_such_capability", "/x", "label", "confirm", 1),
]
async with SessionLocal() as s:
results = await review.review_batch(s, items, actor="root", verified=True)
assert [(r["ok"], r["code"]) for r in results] == [
(True, None), (True, None), (False, review.CODE_PATH), (False, review.CODE_CONFLICT),
(False, review.CODE_MISSING), (False, review.CODE_MISSING)]
assert results[0]["version"] == 2 and "返回字段 /nope 的中文名" in results[2]["error"]
async with SessionLocal() as s:
row = await s.get(Capability, pay)
audits = list((await s.execute(select(AuditEvent).where(AuditEvent.subject_id == pay))).scalars())
untouched = await s.get(Capability, orders)
assert row.version == 2 and row.updated_by == "root"
assert row.fields["/amount"]["label"]["status"] == "confirmed"
assert row.fields["/amount"]["measure"]["status"] == "rejected"
assert untouched.version == 1 and untouched.fields["/status"]["label"]["status"] == "proposed"
assert [a.action for a in audits] == ["capability.review"] and audits[0].data["version"] == 2
assert {(c["field"], c["before"], c["after"]) for c in audits[0].data["items"]} == {
("label", "proposed", "confirmed"), ("measure", "proposed", "rejected")}
# 撤销审阅回到来源的初始状态;人工填写的撤销就是删掉(同数据目录)
async with SessionLocal() as s:
again = await review.review_batch(s, [BatchItem(pay, "/amount", "label", "reset", 2),
BatchItem(pay, "/card", "label", "reset", 2)], actor="root",
verified=True)
assert all(r["ok"] for r in again)
row = await s.get(Capability, pay)
await s.refresh(row)
assert row.fields["/amount"]["label"]["status"] == "proposed" and "/card" not in row.fields
# ---------------------------------------------------------------- 接口与权限
async def test_review_api_and_permissions(caps):
await auth_env.reset()
try:
async with auth_env.client() as c:
queue = (await c.get("/api/capabilities/review-queue", params={"capability": caps["pay"]})).json()
assert [i["field"] for i in queue["items"]] == ["measure", "label"]
r = await c.put(f"/api/capabilities/{caps['orders']}/governance",
json={"version": 1, "fields": {"/status": {"label": "订单状态"}}})
assert r.status_code == 200, r.text
assert r.json()["fields"]["/status"]["label"]["status"] == "confirmed", "治理写进来的人工填写记为已确认"
assert "codes" not in r.json()["fields"]["/status"], "提交里没有的项删掉"
bad = await c.put(f"/api/capabilities/{caps['orders']}/governance",
json={"version": 2, "fields": {"/status": {"measure": "很多"}}})
assert bad.status_code == 422 and "返回字段 /status 的度量类型" in bad.json()["detail"]
admin = await auth_env.turn_on_local()
await auth_env.make_user("bob", "builder")
bob = await auth_env.login("bob")
assert (await bob.get("/api/capabilities/review-queue")).status_code == 200, "搭建者能看"
body = {"items": [{"capability": caps["pay"], "pointer": "/amount", "field": "label", "action": "confirm",
"if_version": 1}]}
assert (await bob.post("/api/capabilities/review/batch", json=body)).status_code == 403, "审阅只给管理员"
r = await admin.post("/api/capabilities/review/batch", json=body)
assert r.status_code == 200 and r.json()["results"][0]["ok"], r.text
async with SessionLocal() as s:
audit = (await s.execute(select(AuditEvent).where(
AuditEvent.subject_id == caps["pay"], AuditEvent.action == "capability.review"))).scalar_one()
assert audit.actor == "root" and audit.verified is True
finally:
await auth_env.reset()