pbl_agent_runtime/scripts/m4a_selftest.py
2026-09-17 23:44:00 +08:00

448 lines
24 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.

# -*- coding: utf-8 -*-
"""
M4a 自测脚本离线可跑MemoryStore—— 覆盖本任务四个验收锚点:
A. Designer/Critic 定义Critic 零写权限)
B. 13 启用 / 9 禁用工具清单注册(含 pbl.publish 类自主发布禁用)
C. fail-closed 8 步裁决顺序default-deny逐步拒绝码正确
D. 四类强制人工审批publish / compile_execute / blueprint_approve /
tool_registry_change
运行python3 scripts/m4a_selftest.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_agent_runtime import m4a as M # noqa: E402
from pbl_agent_runtime import m4a_critic # noqa: E402
from pbl_agent_runtime.m4a_kernel import ( # noqa: E402
MemoryStore, PblError, ADJUDICATION_STEPS, TRACE_ELEMENTS,
E_FORBIDDEN, E_STATE_ILLEGAL, E_TENANT_MISSING, E_VALIDATION,
E_APPEND_ONLY, E_DUPLICATE,
)
PASS, FAIL = [], []
def check(name, cond, detail=""):
(PASS if cond else FAIL).append(name)
print("%s %s%s" % ("[PASS]" if cond else "[FAIL]", name,
(" -> " + str(detail)) if detail and not cond else ""))
def expect_error(name, code, fn):
try:
fn()
except PblError as e:
check(name, e.code == code, "期望 %s 实得 %s(%s)" % (code, e.code, e.message))
return e
except Exception as e:
check(name, False, "非 PblError: %r" % e)
return None
check(name, False, "未抛错(期望 %s" % code)
return None
def main():
store = M.set_store(MemoryStore())
api = M.load_m4a(tenant_id="T001", store=store)
sc = api.self_check()
# ---------------- A. Agent 定义 ----------------
check("A1 仅 designer/critic 两个 Agent", sc["agent_count"] == 2,
sc["agents"])
codes = sorted(a["agent_code"] for a in sc["agents"])
check("A2 agent_code = [critic, designer]", codes == ["critic", "designer"], codes)
critic = [a for a in sc["agents"] if a["agent_code"] == "critic"][0]
designer = [a for a in sc["agents"] if a["agent_code"] == "designer"][0]
check("A3 Critic write_allowed=0零写权限", critic["write_allowed"] == 0,
critic)
check("A4 Designer write_allowed=1", designer["write_allowed"] == 1)
check("A5 Critic 白名单仅只读工具",
set(critic["allowed_tools"]) == set(M.CRITIC_READONLY_TOOLS),
critic["allowed_tools"])
check("A6 sc.critic_write_allowed 为空", sc["critic_write_allowed"] == [])
check("A7 registry 无 critic 可写工具",
sc["tools"]["critic_writable_tools"] == [],
sc["tools"]["critic_writable_tools"])
# ---------------- B. 工具注册表 13+9 ----------------
check("B1 工具总数 22", sc["tools"]["total"] == 22, sc["tools"]["total"])
check("B2 启用 13", sc["tools"]["enabled"] == 13, sc["tools"]["enabled"])
check("B3 禁用 9", sc["tools"]["disabled"] == 9, sc["tools"]["disabled"])
check("B4 禁用项 disable_reason 齐备",
sc["tools"]["disabled_missing_reason"] == [],
sc["tools"]["disabled_missing_reason"])
check("B5 require_approval 工具 = compile.trigger + publish.request",
sorted(sc["tools"]["approval_required"]) ==
["compile.trigger", "publish.request"],
sc["tools"]["approval_required"])
check("B6 自主发布工具 blueprint.publish_auto 已注册且禁用",
any(t["tool_code"] == "blueprint.publish_auto" and
t["status"] == "disabled" for t in api.list_tools()))
dis = {t["tool_code"]: t for t in api.list_tools(status="disabled")}
expect9 = ["blueprint.publish_auto", "curriculum.modify_auto",
"marketplace.create_listing", "kdb.write", "research.collect",
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
"billing.charge"]
check("B7 9 个禁用工具编码完全匹配", sorted(dis) == sorted(expect9),
sorted(dis))
check("B8 kdb.write 禁用Q5 KDB 只读)",
"KDB 只读" in (dis["kdb.write"]["disable_reason"] or ""))
check("B9 research.collect 禁用Q6 禁采集学生数据)",
"research" in (dis["research.collect"]["disable_reason"] or "").lower())
en = {t["tool_code"] for t in api.list_tools(status="enabled")}
check("B10 13 启用工具编码匹配", en == set(M.ENABLED_TOOL_CODES) and len(en) == 13,
sorted(en))
# seed 幂等
r1 = M.seed_tools(ctx=api.admin_ctx(), store=store)
check("B11 seed_tools 幂等(第二次 inserted=0/skipped=22",
r1["inserted"] == 0 and r1["skipped"] == 22, r1)
# ---------------- C. fail-closed 8 步裁决 ----------------
check("C1 裁决链 8 步", len(ADJUDICATION_STEPS) == 8,
[s[0] for s in ADJUDICATION_STEPS])
check("C2 裁决顺序 = S1..S8",
[s[0] for s in ADJUDICATION_STEPS] ==
["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8"],
M.explain_chain())
dctx = api.agent_ctx("designer")
# S1 租户缺失
noctx = M.TenantContext(tenant_id=None, actor_type="agent", actor_id="designer",
permissions={M.PERM_AUTHORING})
v = M.adjudicate(noctx, "designer", "blueprint.get", {"blueprint_id": 1},
store=store)
check("C3 S1 租户缺失 → PBL_E_TENANT_MISSING",
not v.allowed and v.step == "S1" and v.reason_code == E_TENANT_MISSING,
v.to_dict())
# S2 Agent 未注册
v = M.adjudicate(dctx, "mentor", "blueprint.get", {"blueprint_id": 1}, store=store)
check("C4 S2 未注册 Agent(mentor) → PBL_E_FORBIDDEN",
not v.allowed and v.step == "S2" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S3 工具未注册default-deny
v = M.adjudicate(dctx, "designer", "blueprint.delete", {}, store=store)
check("C5 S3 白名单外工具 → PBL_E_FORBIDDEN(default-deny)",
not v.allowed and v.step == "S3" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S4 禁用工具
v = M.adjudicate(dctx, "designer", "blueprint.publish_auto", {}, store=store)
check("C6 S4 禁用工具 → PBL_E_FORBIDDEN + disable_reason",
not v.allowed and v.step == "S4" and v.reason_code == E_FORBIDDEN
and v.tool.get("disable_reason"), v.to_dict())
v = M.adjudicate(dctx, "designer", "kdb.write", {}, store=store)
check("C7 S4 kdb.write 禁用拒绝", not v.allowed and v.step == "S4")
v = M.adjudicate(dctx, "designer", "world.edit_3d", {}, store=store)
check("C8 S4 world.edit_3d 禁用拒绝Q1 无 3D 编辑器)",
not v.allowed and v.step == "S4")
# S5 越权
narrow = M.make_agent_ctx("T001", "designer", permissions={"agent_tools"})
v = M.adjudicate(narrow, "designer", "blueprint.create",
{"intent_text": "x", "title": "y"}, store=store)
check("C9 S5 缺 pbl_authoring 权限域 → PBL_E_FORBIDDEN",
not v.allowed and v.step == "S5" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S6 Critic 零写权限
cctx = api.agent_ctx("critic")
for tc in ("blueprint.create", "blueprint.update", "template.copy",
"compile.trigger", "publish.request"):
v = M.adjudicate(cctx, "critic", tc, {"blueprint_id": 1}, store=store)
check("C10.%s Critic 调写工具被 S6 拒绝" % tc,
not v.allowed and v.step == "S6" and v.reason_code == E_FORBIDDEN,
v.to_dict())
v = M.adjudicate(cctx, "critic", "blueprint.get", {"blueprint_id": 1},
store=store)
check("C11 Critic 调只读 blueprint.get 通过 S6",
v.allowed or v.step in ("S8",), v.to_dict())
# S6 designer 越界调 critic.review
v = M.adjudicate(dctx, "designer", "critic.review", {"blueprint_id": 1},
store=store)
check("C12 S6 designer 调 critic.review 被拒allowed_agents 限定)",
not v.allowed and v.step == "S6", v.to_dict())
# S7 需审批未批
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "org"}, store=store)
check("C13 S7 publish.request 无审批 → PBL_E_STATE_ILLEGAL",
not v.allowed and v.step == "S7" and v.reason_code == E_STATE_ILLEGAL,
v.to_dict())
v = M.adjudicate(dctx, "designer", "compile.trigger",
{"blueprint_id": 7, "version_no": 1,
"compiler_version": "v1"}, store=store)
check("C14 S7 compile.trigger 无审批 → PBL_E_STATE_ILLEGAL",
not v.allowed and v.step == "S7" and v.reason_code == E_STATE_ILLEGAL,
v.to_dict())
# S8 入参契约
v = M.adjudicate(dctx, "designer", "blueprint.create", {"intent_text": "x"},
store=store)
check("C15 S8 缺 title → PBL_E_VALIDATION",
not v.allowed and v.step == "S8" and v.reason_code == E_VALIDATION,
v.to_dict())
v = M.adjudicate(dctx, "designer", "blueprint.create",
{"intent_text": "x", "title": "y",
"generation_source": "bogus"}, store=store)
check("C16 S8 枚举外取值 → PBL_E_VALIDATION",
not v.allowed and v.step == "S8" and v.reason_code == E_VALIDATION,
v.to_dict())
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "galaxy"}, store=store)
check("C16b S7 先于 S8审批门禁优先fail-closed",
not v.allowed and v.step == "S7"
and v.reason_code == E_STATE_ILLEGAL, v.to_dict())
v = M.adjudicate(dctx, "designer", "blueprint.create",
{"intent_text": "x", "title": "y"}, store=store)
check("C17 全通过 → allowed=Truestep=S8",
v.allowed and v.step == "S8", v.to_dict())
# kdb.search 空结果不报错Q5/US-24
r = api.invoke_tool("designer", "kdb.search", {"query": "海洋保护"})
check("C18 kdb.search 桩返回空集不报错",
r.get("stub") is True and r.get("total") == 0 and r.get("items") == [], r)
# 被拒调用也落轨迹US-20
try:
api.invoke_tool("designer", "blueprint.publish_auto", {})
except PblError:
pass
traces = api.list_traces({"agent_code": "designer"}, size=50)
deny_traces = [t for t in traces["items"] if t["status"] == "deny"]
check("C19 被拒调用写入 deny 轨迹", len(deny_traces) >= 1,
traces["total"])
dt = deny_traces[0]
check("C20 deny 轨迹含 proposed_action + result.deny",
bool(dt.get("proposed_action")) and
(dt.get("result") or {}).get("deny") is True, dt.get("result"))
check("C21 deny 轨迹记录 step_reached=S4",
dt.get("step_reached") == "S4", dt.get("step_reached"))
# ---------------- D. 四类强制人工审批 ----------------
check("D1 四类审批类型齐备",
sorted(M.MANDATORY_APPROVAL_TYPES) ==
sorted(["publish", "compile_execute", "blueprint_approve",
"tool_registry_change"]),
M.MANDATORY_APPROVAL_TYPES)
mx = M.mandatory_approval_matrix()
check("D2 审批矩阵 4 条", len(mx) == 4, len(mx))
check("D3 矩阵含 publish 触发工具 publish.request",
any(m["action_type"] == "publish" and
m["trigger_tool"] == "publish.request" for m in mx))
# Agent 自批被拒
ap = api.request_approval("publish", object_id=7, tool_code="publish.request",
object_type="blueprint",
action_payload={"visibility": "org"})
check("D4 发起审批返回 pending", ap["status"] == "pending", ap)
expect_error("D5 Agent 自批被拒PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.decide_approval(api.agent_ctx("designer"),
ap["approval_no"], "approved",
store=store))
# 人类批准
d = api.decide_approval(ap["approval_no"], "approved", "teacher_01",
comment="同意发布到机构")
check("D6 人类批准后 status=approved", d["status"] == "approved", d)
check("D7 approver_type=user", d.get("approver_type") == "user", d)
# 已决不可再决
expect_error("D8 已决审批单不可重复决定", E_STATE_ILLEGAL,
lambda: api.decide_approval(ap["approval_no"], "rejected",
"teacher_02"))
# 批准后 S7 通过
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "org"},
approval_no=ap["approval_no"], store=store)
check("D9 有 approved 记录后 publish.request 通过 S7",
v.allowed and v.approval and
v.approval["approval_no"] == ap["approval_no"], v.to_dict())
# compile_execute 未批仍拒
v = M.adjudicate(dctx, "designer", "compile.trigger",
{"blueprint_id": 7, "version_no": 1,
"compiler_version": "v1"}, store=store)
check("D10 publish 审批不能顶替 compile_execute 审批",
not v.allowed and v.step == "S7", v.to_dict())
# tool_registry_change禁用→启用须审批
expect_error("D11 无审批启用禁用工具 → PBL_E_STATE_ILLEGAL", E_STATE_ILLEGAL,
lambda: api.set_tool_status("kdb.write", "enabled",
admin_id="admin"))
ap2 = api.request_approval("tool_registry_change", object_id="kdb.search",
tool_code="kdb.search", object_type="tool",
action_payload={"to": "enabled"})
api.decide_approval(ap2["approval_no"], "approved", "admin")
# 非 admin 不能改注册表
expect_error("D12 非 Platform Admin 改注册表 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.set_tool_status(api.user_ctx("teacher_01"),
"kdb.search", "disabled",
reason="test", store=store))
# Agent 不能改注册表
expect_error("D13 Agent 改注册表 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.set_tool_status(api.agent_ctx("designer"),
"kdb.search", "disabled",
reason="test", store=store))
r = api.set_tool_status("kdb.search", "disabled", reason="临时下线(测试)",
admin_id="admin")
check("D14 Platform Admin 可禁用工具", r["status"] == "disabled", r)
v = M.adjudicate(dctx, "designer", "kdb.search", {"query": "x"}, store=store)
check("D15 禁用后 S4 立即拒绝", not v.allowed and v.step == "S4", v.to_dict())
ap3 = api.request_approval("tool_registry_change", object_id="kdb.search",
tool_code="kdb.search", object_type="tool")
api.decide_approval(ap3["approval_no"], "approved", "admin")
r = api.set_tool_status("kdb.search", "enabled",
approval_no=ap3["approval_no"], admin_id="admin")
check("D16 审批通过后启用成功", r["status"] == "enabled", r)
# register_tool 重复
expect_error("D17 重复注册 → PBL_E_DUPLICATE", E_DUPLICATE,
lambda: M.register_tool(api.admin_ctx("admin"), "kdb.search",
"x", "kdb", "enabled", "kdb",
store=store))
expect_error("D18 禁用注册缺 disable_reason → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.register_tool(api.admin_ctx("admin"), "foo.bar",
"x", "foo", "disabled", "pbl_authoring",
store=store))
# ---------------- E. 轨迹 append-only + 7 要素 ----------------
check("E1 7 要素定义齐备", list(TRACE_ELEMENTS) ==
["input_context", "retrieved_knowledge", "tool_calls",
"proposed_action", "result", "approval", "final_output"],
TRACE_ELEMENTS)
tn = M.start_trace("designer", session_no="S1",
input_context={"api": "unit"}, ctx=dctx, store=store)
for e in ("retrieved_knowledge", "tool_calls", "proposed_action", "result",
"approval", "final_output"):
M.append_trace(tn, e, {"k": e}, ctx=dctx, store=store)
comp = api.trace_completeness(tn)
check("E2 7 要素全部写入 complete=True", comp["complete"] is True, comp["filled"])
check("E3 要素流水 7 条", comp["stage_log_count"] == 7, comp["stage_log_count"])
M.finish_trace(tn, status="done", step_reached="EXECUTED", ctx=dctx, store=store)
expect_error("E4 已完成轨迹追加 → PBL_E_APPEND_ONLY", E_APPEND_ONLY,
lambda: M.append_trace(tn, "result", {"hack": 1}, ctx=dctx,
store=store))
expect_error("E5 直接 update 轨迹被 append-only 守卫拒绝", E_APPEND_ONLY,
lambda: M.try_mutate_trace(tn, {"status": "open"}, ctx=dctx,
store=store))
expect_error("E6 非法 stage → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.append_trace(tn, "not_a_stage", {}, ctx=dctx,
store=store))
# 读权限
other = M.make_agent_ctx("T001", "critic")
t2 = M.start_trace("designer", session_no="S2", input_context={},
ctx=dctx, store=store)
expect_error("E7 他方 Agent 读轨迹 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.get_trace(t2, ctx=other, store=store))
check("E8 Platform Admin 可读全量轨迹",
M.get_trace(t2, ctx=api.admin_ctx(), store=store)["trace_no"] == t2)
# 跨租户隔离
api2 = M.load_m4a(tenant_id="T002", store=store)
expect_error("E9 跨租户读轨迹 → PBL_E_NOT_FOUND", M.E_NOT_FOUND,
lambda: M.get_trace(t2, ctx=api2.agent_ctx("designer"),
store=store))
# ---------------- F. Designer / Critic 运行时 ----------------
slots, missing = M.parse_intent("为12岁学生设计一个海洋保护主题的PBL"
"4人一组共8课时产出一段视频预算5万")
check("F1 意图解析 age=12", slots["age"] == 12, slots)
check("F2 意图解析 teamSize=4", slots["teamSize"] == 4, slots)
check("F3 意图解析 duration=360", slots["duration"] == 360, slots)
check("F4 意图解析 artifact=video", slots["artifact"] == "video", slots)
check("F5 意图解析 budget=50000", slots["budget"] == 50000, slots)
check("F6 四要素齐备 → missing 为空", missing == [], missing)
_, miss2 = M.parse_intent("做一个关于火星的项目")
check("F7 信息不足 → missing 含四实质字段",
set(miss2) == set(M.CLARIFY_FIELDS), miss2)
ch, _ = M.instruction_to_changes("把预算增加到500万")
check("F8 指令解析为结构化变更 budget=5000000",
len(ch) == 1 and ch[0]["slot"] == "budget"
and ch[0]["value"] == 5000000, ch)
ch2, _ = M.instruction_to_changes("把团队规模改成6人")
check("F9 指令解析 teamSize=6",
any(c["slot"] == "teamSize" and c["value"] == 6 for c in ch2), ch2)
ch3, _ = M.instruction_to_changes("写得更有诗意一些")
check("F10 无法结构化 → 空变更(禁止整篇重写 US-03", ch3 == [], ch3)
q = M.build_clarifications(["age", "duration"], round_no=1)
check("F11 clarify 只问实质缺失字段", len(q) == 2 and
{x["field"] for x in q} == {"age", "duration"}, q)
q4 = M.build_clarifications(["age"], round_no=4)
check("F12 轮次>3 不再追问,给默认假设",
q4 and q4[0].get("exceeded_max_rounds") is True and
q4[0]["question"] is None, q4)
q_ign = M.designer_clarify(None, ["age", "favorite_color"], ctx=dctx,
store=store, session_no="S9", round_no=1)
check("F13 非实质字段被忽略",
q_ign["ignored_fields"] == ["favorite_color"] and
len(q_ign["questions"]) == 1, q_ign)
# Designer 生成(后端未挂载 → 离线兜底 template_fallback非错误
res = api.designer_generate("为12岁学生设计海洋保护PBL4人一组8课时产出视频",
owner_teacher_id="teacher_01",
class_id="C01", session_no="SG1")
check("F14 designer_generate 返回 trace_no", bool(res.get("trace_no")), res)
check("F15 后端未挂载时自动兜底 template_fallbackUS-05 不抛错)",
res.get("generation_source") == "template_fallback", res)
check("F16 兜底提案落 pending_backendAgent 不直连 DB",
res.get("pending_backend") is True or res.get("blueprint_id") is not None
or bool(res.get("code")), res)
comp2 = api.trace_completeness(res["trace_no"])
check("F17 Designer 轨迹含 input_context/retrieved_knowledge/final_output",
comp2["filled"]["input_context"] and
comp2["filled"]["retrieved_knowledge"] and
comp2["filled"]["final_output"], comp2["filled"])
# Critic零写权限 + 四要素
sug = M.assert_suggestion({"recommendation": "补充时长", "reason": "缺 duration",
"evidence": {"field": "duration"}, "confidence": 0.8})
check("F18 四要素齐备建议通过校验", sug["confidence"] == 0.8, sug)
expect_error("F19 缺 evidence → PBL_E_VALIDATION14.3", E_VALIDATION,
lambda: M.assert_suggestion({"recommendation": "a",
"reason": "b", "confidence": 0.5}))
expect_error("F20 confidence 越界 → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.assert_suggestion({"recommendation": "a", "reason": "b",
"evidence": {}, "confidence": 1.5}))
wa = M.critic_write_attempt(api.agent_ctx("critic"), "blueprint.update",
{"blueprint_id": 1, "instruction": "x",
"target_changes": [{"object_type": "scene",
"field": "budget",
"value": 1}]},
store=store)
check("F21 Critic 写蓝图尝试被拒14.1",
wa["allowed"] is False and wa["verdict"] == "PASS"
and wa["step_reached"] == "S6", wa)
wa2 = M.critic_write_attempt(api.agent_ctx("critic"), "publish.request",
{"blueprint_id": 1, "visibility": "org"},
store=store)
check("F22 Critic 发布尝试被拒", wa2["allowed"] is False, wa2)
rules = m4a_critic._rule_suggestions(
{"slots": {"age": 12}},
{"dimensions": [{"dimension_code": "D01", "result": "fail",
"message": "缺学习目标"}],
"alerts": ["时长与阶段数不匹配"], "run": {"run_no": "R1"}})
check("F23 规则式建议覆盖 fail 维度 + 告警 + 缺失槽位",
len(rules) >= 3 and all(
all(k in r for k in M.SUGGESTION_FIELDS) for r in rules), rules)
# ---------------- G. DDL / 表定义 ----------------
sql = M.all_sql()
check("G1 DDL 含 4 张主表 + 流水表",
all(t in sql for t in ("pbl_agent_def", "pbl_agent_tool",
"pbl_agent_trace", "pbl_agent_approval",
"pbl_agent_trace_stage")))
check("G2 append-only 表带标记",
"[APPEND-ONLY" in sql and
sorted(M.APPEND_ONLY_TABLES) ==
["pbl_agent_trace", "pbl_agent_trace_stage"], M.APPEND_ONLY_TABLES)
check("G3 每表 tenant_id 打头(首个业务列)",
all(t["fields"][1]["name"] == "tenant_id" for t in M.TABLES))
check("G4 联合唯一索引以 tenant_id 起首",
all(idx["cols"][0] == "tenant_id"
for t in M.TABLES for idx in t["indexes"] if idx.get("unique")))
print("\n===== M4a 自测汇总PASS=%d FAIL=%d =====" % (len(PASS), len(FAIL)))
if FAIL:
for f in FAIL:
print(" FAILED: %s" % f)
return 1
print("全部通过(%d 项断言)" % len(PASS))
return 0
if __name__ == "__main__":
sys.exit(main())