448 lines
24 KiB
Python
448 lines
24 KiB
Python
# -*- 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=True(step=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岁学生设计海洋保护PBL,4人一组,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_fallback(US-05 不抛错)",
|
||
res.get("generation_source") == "template_fallback", res)
|
||
check("F16 兜底提案落 pending_backend(Agent 不直连 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_VALIDATION(14.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())
|