deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
ef2c2a1816
commit
c2f683690e
265
pbl_agent_runtime/m4a.py
Normal file
265
pbl_agent_runtime/m4a.py
Normal file
@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 对外统一门面(facade)
|
||||
|
||||
装配:
|
||||
from pbl_agent_runtime.m4a import load_m4a, M4aApi
|
||||
api = load_m4a(tenant_id="T001") # 幂等:建表 + seed_agents + seed_tools
|
||||
|
||||
对外契约(modules/pbl_agent_runtime.md §5):
|
||||
Designer : designer_generate / designer_modify / designer_clarify
|
||||
Critic : critic_review / get_critic_report
|
||||
工具裁决 : invoke_tool / adjudicate / register_tool / list_tools /
|
||||
set_tool_status / seed_tools
|
||||
轨迹 : start_trace / append_trace / get_trace / list_traces /
|
||||
trace_completeness
|
||||
审批 : request_approval / decide_approval / get_approval /
|
||||
list_pending_approvals / mandatory_approval_matrix
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
PblError, TenantContext, get_store, set_store, reset_store, fail,
|
||||
MemoryStore, AppendOnlyGuard, canonical_json, now_ts, gen_no,
|
||||
ADJUDICATION_STEPS, TRACE_ELEMENTS,
|
||||
E_TENANT_MISSING, E_FORBIDDEN, E_STATE_ILLEGAL, E_NOT_FOUND, E_VALIDATION,
|
||||
E_DUPLICATE, E_APPEND_ONLY, E_MODEL_UNAVAILABLE, E_BACKEND_UNAVAILABLE,
|
||||
E_TEMPLATE_NONE,
|
||||
ACTOR_USER, ACTOR_AGENT, ACTOR_SYSTEM,
|
||||
)
|
||||
from .m4a_tables import TABLES, APPEND_ONLY_TABLES, to_sql, all_sql, ensure_tables
|
||||
from .m4a_registry import (
|
||||
AGENT_DEFS, TOOL_SEED, ENABLED_TOOL_CODES, DISABLED_TOOL_CODES,
|
||||
APPROVAL_REQUIRED_TOOLS, APPROVAL_TYPES, DESIGNER, CRITIC,
|
||||
PERM_AUTHORING, PERM_AGENT_TOOLS, PERM_PUBLISHING, PERM_KDB,
|
||||
PERM_PLATFORM_ADMIN,
|
||||
seed_agents, seed_tools, get_agent_def, list_agents,
|
||||
register_tool, get_tool, list_tools, set_tool_status, registry_stats,
|
||||
)
|
||||
from .m4a_adjudicate import (
|
||||
Verdict, adjudicate, invoke_tool, validate_args, adjudicate_report,
|
||||
explain_chain,
|
||||
)
|
||||
from .m4a_trace import (
|
||||
start_trace, append_trace, finish_trace, get_trace, list_traces,
|
||||
list_trace_stages, trace_completeness, try_mutate_trace,
|
||||
)
|
||||
from .m4a_approval import (
|
||||
MANDATORY_APPROVAL_TYPES, APPROVAL_PUBLISH, APPROVAL_COMPILE,
|
||||
APPROVAL_BLUEPRINT, APPROVAL_REGISTRY,
|
||||
request_approval, decide_approval, get_approval, list_pending_approvals,
|
||||
list_approvals, find_approved, mandatory_approval_matrix, normalize_action,
|
||||
)
|
||||
from .m4a_designer import (
|
||||
designer_generate, designer_modify, designer_clarify, parse_intent,
|
||||
instruction_to_changes, build_clarifications, set_model_runner,
|
||||
CLARIFY_FIELDS, MAX_CLARIFY_ROUNDS,
|
||||
)
|
||||
from .m4a_critic import (
|
||||
critic_review, get_critic_report, assert_suggestion, critic_write_attempt,
|
||||
set_critic_runner, CRITIC_READONLY_TOOLS, SUGGESTION_FIELDS,
|
||||
)
|
||||
from .m4a_backend import execute as execute_tool_backend, backend_availability
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上下文构造辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_agent_ctx(tenant_id, agent_code=DESIGNER, permissions=None, org_id=None):
|
||||
"""构造 Agent 调用上下文(actor_type=agent,不可审批)。"""
|
||||
perms = set(permissions or ())
|
||||
if not perms:
|
||||
# Agent 默认权限域:authoring + agent_tools + kdb(只读)+ publishing。
|
||||
# 注意:持有 publishing 只代表"可提案"发布(S5 通过),
|
||||
# 真正执行仍被 S7 人工审批门禁拦住(14.2 Publish 必须人工审批)。
|
||||
perms = {PERM_AUTHORING, PERM_AGENT_TOOLS, PERM_KDB, PERM_PUBLISHING}
|
||||
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_AGENT,
|
||||
actor_id=agent_code, permissions=perms, org_id=org_id)
|
||||
|
||||
|
||||
def make_user_ctx(tenant_id, user_id, permissions=None, org_id=None):
|
||||
"""构造人类用户上下文(actor_type=user,可审批 / 可治理注册表)。"""
|
||||
perms = set(permissions or ())
|
||||
if not perms:
|
||||
perms = {PERM_AUTHORING, PERM_PUBLISHING, PERM_AGENT_TOOLS}
|
||||
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_USER,
|
||||
actor_id=user_id, permissions=perms, org_id=org_id)
|
||||
|
||||
|
||||
def make_admin_ctx(tenant_id, user_id="admin"):
|
||||
"""Platform Admin(工具注册表治理 + 全量轨迹可读)。"""
|
||||
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_USER,
|
||||
actor_id=user_id,
|
||||
permissions={PERM_PLATFORM_ADMIN, PERM_AUTHORING,
|
||||
PERM_PUBLISHING, PERM_AGENT_TOOLS,
|
||||
PERM_KDB})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 装配
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_m4a(tenant_id=None, store=None, force_memory=False):
|
||||
"""
|
||||
幂等装配:建表 → seed_agents(designer/critic)→ seed_tools(13+9)。
|
||||
返回 M4aApi 实例。build.sh / 应用 init() 调用。
|
||||
"""
|
||||
st = store or get_store(force_memory=force_memory)
|
||||
ensure_tables(st)
|
||||
ctx = TenantContext(tenant_id=tenant_id or "__platform__",
|
||||
actor_type=ACTOR_SYSTEM, actor_id="seed",
|
||||
permissions={PERM_PLATFORM_ADMIN})
|
||||
agents = seed_agents(ctx=ctx, store=st)
|
||||
tools = seed_tools(ctx=ctx, store=st)
|
||||
return M4aApi(tenant_id=tenant_id or "__platform__", store=st,
|
||||
seed_result={"agents": agents, "tools": tools})
|
||||
|
||||
|
||||
class M4aApi(object):
|
||||
"""按租户绑定的 M4a 门面(避免每个调用都传 store/tenant)。"""
|
||||
|
||||
def __init__(self, tenant_id, store=None, seed_result=None):
|
||||
self.tenant_id = tenant_id
|
||||
self.store = store or get_store()
|
||||
self.seed_result = seed_result or {}
|
||||
|
||||
# --- 上下文 ---
|
||||
def agent_ctx(self, agent_code=DESIGNER, permissions=None):
|
||||
return make_agent_ctx(self.tenant_id, agent_code, permissions)
|
||||
|
||||
def user_ctx(self, user_id, permissions=None):
|
||||
return make_user_ctx(self.tenant_id, user_id, permissions)
|
||||
|
||||
def admin_ctx(self, user_id="admin"):
|
||||
return make_admin_ctx(self.tenant_id, user_id)
|
||||
|
||||
# --- Designer ---
|
||||
def designer_generate(self, intent_text, owner_teacher_id=None, class_id=None,
|
||||
session_no=None, model_runner=None):
|
||||
return designer_generate(intent_text, owner_teacher_id=owner_teacher_id,
|
||||
class_id=class_id, session_no=session_no,
|
||||
ctx=self.agent_ctx(DESIGNER), store=self.store,
|
||||
model_runner=model_runner)
|
||||
|
||||
def designer_modify(self, blueprint_id, instruction, session_no=None,
|
||||
version_no=None):
|
||||
return designer_modify(blueprint_id, instruction, session_no=session_no,
|
||||
ctx=self.agent_ctx(DESIGNER), store=self.store,
|
||||
version_no=version_no)
|
||||
|
||||
def designer_clarify(self, blueprint_id, missing_fields, session_no=None,
|
||||
round_no=None):
|
||||
return designer_clarify(blueprint_id, missing_fields,
|
||||
ctx=self.agent_ctx(DESIGNER), store=self.store,
|
||||
session_no=session_no, round_no=round_no)
|
||||
|
||||
# --- Critic ---
|
||||
def critic_review(self, blueprint_id, version_no=None, session_no=None):
|
||||
return critic_review(blueprint_id, version_no=version_no,
|
||||
ctx=self.agent_ctx(CRITIC), store=self.store,
|
||||
session_no=session_no)
|
||||
|
||||
def get_critic_report(self, trace_no):
|
||||
return get_critic_report(trace_no, ctx=self.admin_ctx(), store=self.store)
|
||||
|
||||
# --- 工具裁决 ---
|
||||
def invoke_tool(self, agent_code, tool_code, args=None, trace_no=None,
|
||||
session_no=None, approval_no=None):
|
||||
return invoke_tool(self.agent_ctx(agent_code), agent_code, tool_code,
|
||||
args=args, trace_no=trace_no, session_no=session_no,
|
||||
approval_no=approval_no, store=self.store)
|
||||
|
||||
def adjudicate(self, agent_code, tool_code, args=None):
|
||||
return adjudicate_report(self.agent_ctx(agent_code), agent_code, tool_code,
|
||||
args=args, store=self.store)
|
||||
|
||||
def list_tools(self, status=None, group=None):
|
||||
return list_tools(ctx=self.admin_ctx(), status=status, group=group,
|
||||
store=self.store)
|
||||
|
||||
def registry_stats(self):
|
||||
return registry_stats(ctx=self.admin_ctx(), store=self.store)
|
||||
|
||||
def set_tool_status(self, tool_code, status, reason=None, admin_id="admin",
|
||||
approval_no=None):
|
||||
return set_tool_status(self.admin_ctx(admin_id), tool_code, status,
|
||||
reason=reason or approval_no, store=self.store)
|
||||
|
||||
# --- 轨迹 ---
|
||||
def get_trace(self, trace_no):
|
||||
return get_trace(trace_no, ctx=self.admin_ctx(), store=self.store)
|
||||
|
||||
def list_traces(self, filters=None, page=1, size=20):
|
||||
return list_traces(filters, page=page, size=size, ctx=self.admin_ctx(),
|
||||
store=self.store)
|
||||
|
||||
def trace_completeness(self, trace_no):
|
||||
return trace_completeness(trace_no, ctx=self.admin_ctx(), store=self.store)
|
||||
|
||||
# --- 审批 ---
|
||||
def request_approval(self, action_type, object_id=None, agent_code=DESIGNER,
|
||||
tool_code=None, object_type=None, action_payload=None,
|
||||
approver_id=None, trace_no=None, trace_id=None):
|
||||
return request_approval(self.agent_ctx(agent_code), trace_id=trace_id,
|
||||
action_type=action_type, agent_code=agent_code,
|
||||
tool_code=tool_code, object_type=object_type,
|
||||
object_id=object_id,
|
||||
action_payload=action_payload,
|
||||
approver_id=approver_id, trace_no=trace_no,
|
||||
store=self.store)
|
||||
|
||||
def decide_approval(self, approval_no, status, user_id, comment=None):
|
||||
return decide_approval(self.user_ctx(user_id), approval_no, status,
|
||||
comment=comment, store=self.store)
|
||||
|
||||
def list_pending_approvals(self, approver_id):
|
||||
return list_pending_approvals(self.user_ctx(approver_id),
|
||||
approver_id=approver_id, store=self.store)
|
||||
|
||||
# --- 自检 ---
|
||||
def self_check(self):
|
||||
"""M4a 装配自检:Agent 定义 / 工具计数 / 审批矩阵 / 裁决链步数。"""
|
||||
agents = list_agents(ctx=self.admin_ctx(), store=self.store)
|
||||
stats = self.registry_stats()
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"agents": [{"agent_code": a["agent_code"],
|
||||
"write_allowed": a.get("write_allowed"),
|
||||
"status": a.get("status"),
|
||||
"allowed_tools": a.get("allowed_tools")} for a in agents],
|
||||
"agent_count": len(agents),
|
||||
"critic_write_allowed": [a["agent_code"] for a in agents
|
||||
if a["agent_code"] == CRITIC
|
||||
and a.get("write_allowed") in (1, True)],
|
||||
"tools": stats,
|
||||
"adjudication_steps": [s[0] for s in ADJUDICATION_STEPS],
|
||||
"adjudication_step_count": len(ADJUDICATION_STEPS),
|
||||
"mandatory_approvals": [m["action_type"]
|
||||
for m in mandatory_approval_matrix()],
|
||||
"trace_elements": list(TRACE_ELEMENTS),
|
||||
"append_only_tables": list(APPEND_ONLY_TABLES),
|
||||
"backend": backend_availability(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"load_m4a", "M4aApi", "make_agent_ctx", "make_user_ctx", "make_admin_ctx",
|
||||
"TenantContext", "PblError", "get_store", "set_store", "reset_store",
|
||||
"MemoryStore", "AppendOnlyGuard", "ensure_tables", "TABLES", "to_sql",
|
||||
"all_sql", "APPEND_ONLY_TABLES",
|
||||
"seed_agents", "seed_tools", "get_agent_def", "list_agents",
|
||||
"register_tool", "get_tool", "list_tools", "set_tool_status",
|
||||
"registry_stats", "AGENT_DEFS", "TOOL_SEED", "ENABLED_TOOL_CODES",
|
||||
"DISABLED_TOOL_CODES", "APPROVAL_REQUIRED_TOOLS", "APPROVAL_TYPES",
|
||||
"adjudicate", "adjudicate_report", "invoke_tool", "validate_args",
|
||||
"explain_chain", "Verdict", "ADJUDICATION_STEPS",
|
||||
"start_trace", "append_trace", "finish_trace", "get_trace", "list_traces",
|
||||
"list_trace_stages", "trace_completeness", "try_mutate_trace",
|
||||
"TRACE_ELEMENTS",
|
||||
"request_approval", "decide_approval", "get_approval",
|
||||
"list_pending_approvals", "list_approvals", "find_approved",
|
||||
"mandatory_approval_matrix", "MANDATORY_APPROVAL_TYPES",
|
||||
"designer_generate", "designer_modify", "designer_clarify", "parse_intent",
|
||||
"instruction_to_changes", "set_model_runner",
|
||||
"critic_review", "get_critic_report", "assert_suggestion",
|
||||
"critic_write_attempt", "set_critic_runner",
|
||||
"backend_availability", "DESIGNER", "CRITIC",
|
||||
]
|
||||
371
pbl_agent_runtime/m4a_adjudicate.py
Normal file
371
pbl_agent_runtime/m4a_adjudicate.py
Normal file
@ -0,0 +1,371 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — fail-closed 工具裁决(8 步,default-deny)
|
||||
|
||||
裁决顺序(modules/pbl_agent_runtime.md §4 六步 + 本任务要求的 8 步细化,顺序不可变):
|
||||
S1 租户上下文有效 → PBL_E_TENANT_MISSING
|
||||
S2 Agent 已注册且 enabled → PBL_E_FORBIDDEN
|
||||
S3 工具已注册(白名单外拒)→ PBL_E_FORBIDDEN
|
||||
S4 工具 status=enabled → PBL_E_FORBIDDEN(返回 disable_reason)
|
||||
S5 required_permission → PBL_E_FORBIDDEN(越权拒绝)
|
||||
S6 allowed_agents + Critic 零写权限 → PBL_E_FORBIDDEN
|
||||
S7 require_approval=1 须 approved → PBL_E_STATE_ILLEGAL
|
||||
S8 input_schema 契约校验 → PBL_E_VALIDATION
|
||||
全通过 → 执行后端映射 → 轨迹落库(7 要素)
|
||||
|
||||
铁律:
|
||||
* default-deny —— 任何一步失败立即返回拒绝,绝不"默认放行"
|
||||
* 被拒调用同样写轨迹(proposed_action + result=deny + 原因),可审计(US-20)
|
||||
* Agent 不能自批(S7 只认 actor_type=user 决出的 approved 记录)
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, write_audit, gen_no,
|
||||
PblError, ADJUDICATION_STEPS,
|
||||
E_FORBIDDEN, E_STATE_ILLEGAL, E_TENANT_MISSING, E_VALIDATION, E_NOT_FOUND,
|
||||
TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED, AGENT_STATUS_ENABLED,
|
||||
TRACE_STATUS_DENY, ACTOR_AGENT,
|
||||
)
|
||||
from .m4a_registry import get_tool, get_agent_def, CRITIC, DESIGNER
|
||||
from . import m4a_trace as trace_mod
|
||||
from . import m4a_approval as approval_mod
|
||||
from . import m4a_backend as backend_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 裁决结果对象
|
||||
# ---------------------------------------------------------------------------
|
||||
class Verdict(object):
|
||||
"""一次裁决的完整结论(allow / deny + 到达步 + 原因),可序列化入轨迹。"""
|
||||
|
||||
def __init__(self, allowed, step, check, reason_code, reason, tool=None,
|
||||
agent=None, approval=None):
|
||||
self.allowed = allowed
|
||||
self.step = step # S1~S8 / EXECUTED
|
||||
self.check = check # 检查项名
|
||||
self.reason_code = reason_code
|
||||
self.reason = reason
|
||||
self.tool = tool or {}
|
||||
self.agent = agent or {}
|
||||
self.approval = approval
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"step_reached": self.step,
|
||||
"check": self.check,
|
||||
"reason_code": self.reason_code,
|
||||
"reason": self.reason,
|
||||
"tool_code": self.tool.get("tool_code"),
|
||||
"tool_status": self.tool.get("status"),
|
||||
"disable_reason": self.tool.get("disable_reason"),
|
||||
"agent_code": self.agent.get("agent_code"),
|
||||
"approval_no": (self.approval or {}).get("approval_no"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def allow(step, tool, agent, approval=None):
|
||||
return Verdict(True, step, "pass", None, "裁决通过", tool, agent, approval)
|
||||
|
||||
@staticmethod
|
||||
def deny(step, check, code, reason, tool=None, agent=None):
|
||||
return Verdict(False, step, check, code, reason, tool, agent)
|
||||
|
||||
|
||||
def _deny(step_id, check, code, reason, tool=None, agent=None):
|
||||
return Verdict.deny(step_id, check, code, reason, tool, agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8 步裁决链
|
||||
# ---------------------------------------------------------------------------
|
||||
def adjudicate(ctx, agent_code, tool_code, args=None, trace_no=None,
|
||||
approval_no=None, store=None):
|
||||
"""
|
||||
执行 fail-closed 8 步裁决。返回 Verdict(不抛异常,便于把 deny 落轨迹)。
|
||||
args 仅用于 S8 契约校验,不做任何副作用。
|
||||
"""
|
||||
st = store or get_store()
|
||||
args = args if isinstance(args, dict) else {}
|
||||
|
||||
# ---- S1 租户上下文 ----
|
||||
if ctx is None or not getattr(ctx, "tenant_id", None) \
|
||||
or not str(ctx.tenant_id).strip():
|
||||
return _deny("S1", "tenant_context", E_TENANT_MISSING,
|
||||
"租户上下文缺失或非法(tenant_id 强制打头)",
|
||||
tool={"tool_code": tool_code},
|
||||
agent={"agent_code": agent_code})
|
||||
tenant_id = str(ctx.tenant_id).strip()
|
||||
|
||||
# ---- S2 Agent 已注册且 enabled ----
|
||||
try:
|
||||
agent = get_agent_def(agent_code, ctx=ctx, store=st)
|
||||
except PblError:
|
||||
agent = None
|
||||
if not agent:
|
||||
return _deny("S2", "agent_registered", E_FORBIDDEN,
|
||||
"Agent 未注册(本迭代仅 designer / critic 两个,13.1 章)",
|
||||
tool={"tool_code": tool_code},
|
||||
agent={"agent_code": agent_code})
|
||||
if agent.get("status") != AGENT_STATUS_ENABLED:
|
||||
return _deny("S2", "agent_registered", E_FORBIDDEN,
|
||||
"Agent 已停用: %s" % agent_code, tool={"tool_code": tool_code},
|
||||
agent=agent)
|
||||
|
||||
# ---- S3 工具已注册(default-deny:白名单外一律拒) ----
|
||||
tool = get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id)
|
||||
if not tool:
|
||||
return _deny("S3", "tool_registered", E_FORBIDDEN,
|
||||
"工具未注册,default-deny 拒绝(第 31 章裁剪白名单外不可用)",
|
||||
tool={"tool_code": tool_code}, agent=agent)
|
||||
|
||||
# ---- S4 工具 enabled ----
|
||||
if tool.get("status") != TOOL_STATUS_ENABLED:
|
||||
return _deny("S4", "tool_enabled", E_FORBIDDEN,
|
||||
"工具已禁用(out_of_scope):%s"
|
||||
% (tool.get("disable_reason") or "无说明"),
|
||||
tool=tool, agent=agent)
|
||||
|
||||
# ---- S5 权限域 ----
|
||||
perm = tool.get("required_permission")
|
||||
if perm and not ctx.has_perm(perm):
|
||||
return _deny("S5", "permission_domain", E_FORBIDDEN,
|
||||
"越权拒绝:调用方缺少权限域 %s" % perm, tool=tool, agent=agent)
|
||||
|
||||
# ---- S6 Agent 作用域 + Critic 零写权限 ----
|
||||
allowed_agents = tool.get("allowed_agents") or []
|
||||
denied_agents = agent.get("denied_tools") or []
|
||||
if tool_code in denied_agents:
|
||||
return _deny("S6", "agent_scope", E_FORBIDDEN,
|
||||
"该 Agent 显式禁用此工具: %s → %s" % (agent_code, tool_code),
|
||||
tool=tool, agent=agent)
|
||||
if allowed_agents and agent_code not in allowed_agents:
|
||||
return _deny("S6", "agent_scope", E_FORBIDDEN,
|
||||
"工具 %s 仅允许 %s 调用,%s 越界"
|
||||
% (tool_code, "/".join(allowed_agents), agent_code),
|
||||
tool=tool, agent=agent)
|
||||
# Critic 零写权限(14.1):双重防线——即使注册表误配也拒绝
|
||||
if agent.get("write_allowed") in (0, False) and tool.get("write_operation") in (1, True):
|
||||
return _deny("S6", "agent_scope", E_FORBIDDEN,
|
||||
"Critic 零写权限(14.1):不得调用写操作工具 %s" % tool_code,
|
||||
tool=tool, agent=agent)
|
||||
if agent_code == CRITIC and tool.get("write_operation") in (1, True):
|
||||
return _deny("S6", "agent_scope", E_FORBIDDEN,
|
||||
"Critic 零写权限(14.1):不得调用写操作工具 %s" % tool_code,
|
||||
tool=tool, agent=agent)
|
||||
|
||||
# ---- S7 强制人工审批 ----
|
||||
approval = None
|
||||
if tool.get("require_approval") in (1, True):
|
||||
approval = approval_mod.find_approved(
|
||||
tool.get("approval_action_type") or "execute",
|
||||
args.get("blueprint_id") or args.get("object_id") or tool_code,
|
||||
ctx=ctx, store=st, tenant_id=tenant_id, approval_no=approval_no,
|
||||
trace_no=trace_no, tool_code=tool_code)
|
||||
if approval is None:
|
||||
return _deny("S7", "approval_gate", E_STATE_ILLEGAL,
|
||||
"工具 %s 需人工审批(%s),无 approved 记录 → 拒绝执行(14.2)"
|
||||
% (tool_code, tool.get("approval_action_type") or "execute"),
|
||||
tool=tool, agent=agent)
|
||||
|
||||
# ---- S8 入参契约 ----
|
||||
errs = validate_args(tool.get("input_schema") or {}, args)
|
||||
if errs:
|
||||
return _deny("S8", "input_contract", E_VALIDATION,
|
||||
"入参不满足工具契约: %s" % "; ".join(errs), tool=tool, agent=agent)
|
||||
|
||||
return Verdict.allow("S8", tool, agent, approval)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S8 入参契约校验(轻量 JSON Schema 子集:required / type / enum / minItems)
|
||||
# ---------------------------------------------------------------------------
|
||||
_TYPE_MAP = {
|
||||
"string": str, "integer": int, "number": (int, float),
|
||||
"boolean": bool, "object": dict, "array": (list, tuple),
|
||||
}
|
||||
|
||||
|
||||
def _type_ok(value, tname):
|
||||
if tname not in _TYPE_MAP:
|
||||
return True
|
||||
expect = _TYPE_MAP[tname]
|
||||
if tname == "integer" and isinstance(value, bool):
|
||||
return False
|
||||
if tname == "string":
|
||||
return isinstance(value, str)
|
||||
return isinstance(value, expect)
|
||||
|
||||
|
||||
def validate_args(schema, args):
|
||||
"""返回错误列表(空=通过)。schema 为 input_schema(JSON Schema 子集)。"""
|
||||
errs = []
|
||||
if not isinstance(schema, dict) or not schema:
|
||||
return errs
|
||||
args = args if isinstance(args, dict) else {}
|
||||
props = schema.get("properties") or {}
|
||||
for name in schema.get("required") or []:
|
||||
if name not in args or args[name] is None or args[name] == "":
|
||||
errs.append("缺少必填参数 %s" % name)
|
||||
for name, spec in props.items():
|
||||
if name not in args or args[name] is None:
|
||||
continue
|
||||
val = args[name]
|
||||
tname = spec.get("type")
|
||||
if tname and not _type_ok(val, tname):
|
||||
errs.append("参数 %s 类型应为 %s,实为 %s"
|
||||
% (name, tname, type(val).__name__))
|
||||
continue
|
||||
if spec.get("enum") and val not in spec["enum"]:
|
||||
errs.append("参数 %s 取值须在 %s 内,实为 %r"
|
||||
% (name, spec["enum"], val))
|
||||
if tname == "array":
|
||||
if spec.get("minItems") and len(val) < spec["minItems"]:
|
||||
errs.append("参数 %s 至少 %s 项" % (name, spec["minItems"]))
|
||||
item_spec = spec.get("items") or {}
|
||||
if isinstance(item_spec, dict) and item_spec.get("required"):
|
||||
for i, item in enumerate(val):
|
||||
if not isinstance(item, dict):
|
||||
errs.append("参数 %s[%d] 应为对象" % (name, i))
|
||||
continue
|
||||
for rq in item_spec["required"]:
|
||||
if rq not in item or item[rq] is None:
|
||||
errs.append("参数 %s[%d] 缺少 %s" % (name, i, rq))
|
||||
return errs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具调用(裁决 + 执行 + 轨迹)
|
||||
# ---------------------------------------------------------------------------
|
||||
def invoke_tool(ctx, agent_code, tool_code, args=None, trace_no=None,
|
||||
session_no=None, approval_no=None, store=None,
|
||||
input_context=None):
|
||||
"""
|
||||
Agent 请求工具调用的唯一入口。
|
||||
|
||||
流程:开轨迹(若未给 trace_no)→ 8 步裁决 → 拒绝则落 deny 轨迹并抛 PblError
|
||||
→ 通过则执行后端映射 → tool_calls/result/final_output 落轨迹 → 审计。
|
||||
"""
|
||||
st = store or get_store()
|
||||
args = args if isinstance(args, dict) else {}
|
||||
|
||||
own_trace = trace_no is None
|
||||
if not trace_no:
|
||||
trace_no = trace_mod.start_trace(
|
||||
agent_code, session_no=session_no,
|
||||
input_context=input_context or {"tool_code": tool_code, "args": args},
|
||||
ctx=ctx, store=st)
|
||||
tr = trace_mod.get_trace(trace_no, ctx=ctx, store=st, internal=True)
|
||||
|
||||
verdict = adjudicate(ctx, agent_code, tool_code, args=args, trace_no=trace_no,
|
||||
approval_no=approval_no, store=st)
|
||||
|
||||
# 提案动作(Agent 只 Propose,14.1)——通过与拒绝都记
|
||||
proposed = {
|
||||
"agent_code": agent_code, "tool_code": tool_code, "args": args,
|
||||
"authority": "propose_only",
|
||||
"note": "Agent 非真相源:写操作经工具落到权威系统(14.1)",
|
||||
}
|
||||
trace_mod.append_trace(trace_no, "proposed_action", proposed, ctx=ctx, store=st)
|
||||
|
||||
if not verdict.allowed:
|
||||
trace_mod.append_trace(trace_no, "tool_calls", {
|
||||
"tool_code": tool_code, "args": args, "executed": False,
|
||||
"denied_at": verdict.step, "check": verdict.check,
|
||||
"reason_code": verdict.reason_code, "reason": verdict.reason,
|
||||
"disable_reason": verdict.tool.get("disable_reason"),
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.append_trace(trace_no, "result", {
|
||||
"allow": False, "deny": True, "step_reached": verdict.step,
|
||||
"error_code": verdict.reason_code, "reason": verdict.reason,
|
||||
}, ctx=ctx, store=st)
|
||||
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
|
||||
step_reached=verdict.step,
|
||||
deny_code=verdict.reason_code,
|
||||
deny_reason=verdict.reason, ctx=ctx, store=st)
|
||||
fail(verdict.reason_code, verdict.reason,
|
||||
tool_code=tool_code, agent_code=agent_code,
|
||||
step_reached=verdict.step, check=verdict.check,
|
||||
disable_reason=verdict.tool.get("disable_reason"),
|
||||
trace_no=trace_no)
|
||||
|
||||
# ---- 执行后端映射 ----
|
||||
try:
|
||||
result = backend_mod.execute(ctx, verdict.tool, args, trace_no=trace_no,
|
||||
approval=verdict.approval, store=st)
|
||||
exec_err = None
|
||||
except PblError as e:
|
||||
result, exec_err = None, e
|
||||
except Exception as e: # 后端异常不外泄细节,统一错误码
|
||||
result, exec_err = None, PblError(E_STATE_ILLEGAL,
|
||||
"工具后端执行失败: %s" % e.__class__.__name__,
|
||||
tool_code=tool_code)
|
||||
|
||||
trace_mod.append_trace(trace_no, "tool_calls", {
|
||||
"tool_code": tool_code, "args": args, "executed": exec_err is None,
|
||||
"backend_mapping": verdict.tool.get("backend_mapping"),
|
||||
"output": result if exec_err is None else None,
|
||||
"error": exec_err.to_dict() if exec_err else None,
|
||||
"called_at": now_ts(),
|
||||
}, ctx=ctx, store=st)
|
||||
|
||||
if verdict.approval:
|
||||
trace_mod.append_trace(trace_no, "approval", {
|
||||
"approval_no": verdict.approval.get("approval_no"),
|
||||
"action_type": verdict.approval.get("action_type"),
|
||||
"status": verdict.approval.get("status"),
|
||||
"decided_by": verdict.approval.get("decided_by"),
|
||||
"approver_type": verdict.approval.get("approver_type"),
|
||||
"note": "人工审批通过后方可执行(14.2)",
|
||||
}, ctx=ctx, store=st)
|
||||
|
||||
if exec_err is not None:
|
||||
trace_mod.append_trace(trace_no, "result", {
|
||||
"allow": True, "executed": False, "error_code": exec_err.code,
|
||||
"reason": exec_err.message,
|
||||
}, ctx=ctx, store=st)
|
||||
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
|
||||
step_reached="EXECUTED", deny_code=exec_err.code,
|
||||
deny_reason=exec_err.message, ctx=ctx, store=st)
|
||||
raise exec_err
|
||||
|
||||
trace_mod.append_trace(trace_no, "result", {
|
||||
"allow": True, "executed": True, "tool_code": tool_code, "output": result,
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.append_trace(trace_no, "final_output", result, ctx=ctx, store=st)
|
||||
_finish_own(trace_no, own_trace, status="done", step_reached="EXECUTED",
|
||||
ctx=ctx, store=st)
|
||||
|
||||
# 审计联动(§4.5):object_type=tool_code
|
||||
write_audit(ctx, "tool.invoke", tool_code, trace_no,
|
||||
{"agent_code": agent_code, "trace_no": trace_no,
|
||||
"approval_no": (verdict.approval or {}).get("approval_no")},
|
||||
store=st)
|
||||
|
||||
out = dict(result) if isinstance(result, dict) else {"value": result}
|
||||
out.setdefault("trace_no", trace_no)
|
||||
out["_verdict"] = verdict.to_dict()
|
||||
return out
|
||||
|
||||
|
||||
def _finish_own(trace_no, own, **kw):
|
||||
"""仅当本次 invoke_tool 自己开的轨迹才收口;父轨迹(Designer/Critic 流程)
|
||||
由父流程负责收口,避免子工具调用把外层轨迹提前冻结(append-only)。"""
|
||||
if not own:
|
||||
return None
|
||||
return trace_mod.finish_trace(trace_no, **kw)
|
||||
|
||||
|
||||
def adjudicate_report(ctx, agent_code, tool_code, args=None, store=None):
|
||||
"""
|
||||
只裁决不执行(供前端"权限预检"与自动化测试锚点:越权工具调用拒绝)。
|
||||
返回 Verdict.to_dict()。
|
||||
"""
|
||||
v = adjudicate(ctx, agent_code, tool_code, args=args, store=store)
|
||||
return v.to_dict()
|
||||
|
||||
|
||||
def explain_chain():
|
||||
"""输出 8 步裁决顺序说明(供文档/前端展示,与 ADJUDICATION_STEPS 同源)。"""
|
||||
return [{"step": s[0], "check": s[1], "description": s[2], "error_code": s[3]}
|
||||
for s in ADJUDICATION_STEPS]
|
||||
305
pbl_agent_runtime/m4a_approval.py
Normal file
305
pbl_agent_runtime/m4a_approval.py
Normal file
@ -0,0 +1,305 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 人工审批(14.2 四类强制审批)
|
||||
|
||||
四类强制人工审批(缺一即拒绝执行):
|
||||
1. publish 发布(T12 publish.request,require_approval=1)
|
||||
—— Publish 必须人工审批(14.2 / 36 章),无自主发布(D1 禁用)
|
||||
2. compile_execute 编译执行(T11 compile.trigger,require_approval=1)
|
||||
—— 未审批蓝图不可编译(US-10,后端 is_approved 门禁)
|
||||
3. blueprint_approve 蓝图 approved 状态推进(draft → approved 由教师拍板,
|
||||
Agent 只能提案,不得自批)
|
||||
4. tool_registry_change 工具注册表启停变更(禁用→启用须人工审批,
|
||||
防止 Agent/脚本私自扩权,default-deny 不被绕过)
|
||||
|
||||
铁律:
|
||||
* Agent 不能自批 —— decide_approval 仅 actor_type=user 可调(agent-tool-contract.md §4.3)
|
||||
* 已决(approved/rejected/expired)审批单不可再决 → PBL_E_STATE_ILLEGAL
|
||||
* 指定了 approver_id 时,仅该审批人可决 → PBL_E_FORBIDDEN
|
||||
* find_approved 只认 status=approved 且未过期且 actor 为人类的记录
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, gen_no, write_audit,
|
||||
E_NOT_FOUND, E_FORBIDDEN, E_STATE_ILLEGAL, E_VALIDATION, E_TENANT_MISSING,
|
||||
ACTOR_USER, ACTOR_AGENT,
|
||||
)
|
||||
|
||||
APPROVAL_PUBLISH = "publish"
|
||||
APPROVAL_COMPILE = "compile_execute"
|
||||
APPROVAL_BLUEPRINT = "blueprint_approve"
|
||||
APPROVAL_REGISTRY = "tool_registry_change"
|
||||
|
||||
MANDATORY_APPROVAL_TYPES = (APPROVAL_PUBLISH, APPROVAL_COMPILE,
|
||||
APPROVAL_BLUEPRINT, APPROVAL_REGISTRY)
|
||||
|
||||
# 兼容 agent-tool-contract.md T10 的 action_type 枚举(execute → compile_execute)
|
||||
ACTION_ALIASES = {"execute": APPROVAL_COMPILE, "compile": APPROVAL_COMPILE,
|
||||
"publish": APPROVAL_PUBLISH,
|
||||
"blueprint_approve": APPROVAL_BLUEPRINT,
|
||||
"tool_registry_change": APPROVAL_REGISTRY}
|
||||
|
||||
ST_PENDING = "pending"
|
||||
ST_APPROVED = "approved"
|
||||
ST_REJECTED = "rejected"
|
||||
ST_EXPIRED = "expired"
|
||||
|
||||
|
||||
def normalize_action(action_type):
|
||||
a = ACTION_ALIASES.get(str(action_type or "").strip())
|
||||
if a is None:
|
||||
fail(E_VALIDATION,
|
||||
"action_type 非法,四类强制审批之一: %s" % (MANDATORY_APPROVAL_TYPES,),
|
||||
action_type=action_type)
|
||||
return a
|
||||
|
||||
|
||||
def _tenant(ctx):
|
||||
if ctx is None or not getattr(ctx, "tenant_id", None):
|
||||
fail(E_TENANT_MISSING, "审批操作缺少租户上下文")
|
||||
return str(ctx.tenant_id).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 发起审批(Agent 提案侧)
|
||||
# ---------------------------------------------------------------------------
|
||||
def request_approval(ctx, trace_id=None, action_type=None, agent_code=None,
|
||||
tool_code=None, object_type=None, object_id=None,
|
||||
action_payload=None, approver_id=None, trace_no=None,
|
||||
expires_in_hours=72, store=None):
|
||||
"""
|
||||
发起人工审批单(T10 approval.request)。返回 {approval_no, status:"pending"}。
|
||||
Agent 只能提案,不能决定;requester_type 由 ctx.actor_type 推导。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
action = normalize_action(action_type)
|
||||
|
||||
# 同一对象已有 pending 单 → 复用(幂等,避免刷单)
|
||||
exist = _find_pending(st, tenant_id, action, object_id, tool_code)
|
||||
if exist:
|
||||
return {"approval_no": exist["approval_no"], "status": ST_PENDING,
|
||||
"reused": True, "action_type": action}
|
||||
|
||||
no = gen_no("APR")
|
||||
row = {
|
||||
"tenant_id": tenant_id, "approval_no": no,
|
||||
"trace_id": trace_id, "trace_no": trace_no,
|
||||
"agent_code": agent_code or (ctx.actor_id if ctx.actor_type == ACTOR_AGENT else None),
|
||||
"action_type": action, "tool_code": tool_code,
|
||||
"object_type": object_type, "object_id": dumps(object_id) if isinstance(object_id, (dict, list)) else (str(object_id) if object_id is not None else None),
|
||||
"action_payload": dumps(action_payload or {}),
|
||||
"status": ST_PENDING,
|
||||
"requester_type": ctx.actor_type or ACTOR_AGENT,
|
||||
"requester_id": ctx.actor_id,
|
||||
"approver_id": approver_id, "approver_type": None,
|
||||
"decided_by": None, "decided_at": None, "comment": None,
|
||||
"expires_at": _expires(expires_in_hours),
|
||||
"created_at": now_ts(), "updated_at": now_ts(),
|
||||
}
|
||||
aid = st.C("pbl_agent_approval", row)
|
||||
write_audit(ctx, "approval.request", "pbl_agent_approval", aid,
|
||||
{"approval_no": no, "action_type": action,
|
||||
"object_id": row["object_id"]}, store=st)
|
||||
row["id"] = aid
|
||||
return {"approval_no": no, "status": ST_PENDING, "id": aid,
|
||||
"action_type": action, "reused": False}
|
||||
|
||||
|
||||
def _expires(hours):
|
||||
import time
|
||||
if not hours:
|
||||
return None
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() + int(hours) * 3600))
|
||||
|
||||
|
||||
def _find_pending(st, tenant_id, action, object_id, tool_code):
|
||||
oid = dumps(object_id) if isinstance(object_id, (dict, list)) else (
|
||||
str(object_id) if object_id is not None else None)
|
||||
rows = st.R("pbl_agent_approval",
|
||||
{"tenant_id": tenant_id, "action_type": action, "status": ST_PENDING},
|
||||
order_by="-id")
|
||||
for r in rows:
|
||||
if oid is not None and str(r.get("object_id")) == oid:
|
||||
return r
|
||||
if oid is None and tool_code and r.get("tool_code") == tool_code:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 审批决定(仅人类)
|
||||
# ---------------------------------------------------------------------------
|
||||
def decide_approval(ctx, approval_no, status, comment=None, store=None):
|
||||
"""
|
||||
审批决定。仅 actor_type=user(人类)可调;Agent 自批 → PBL_E_FORBIDDEN(14.2)。
|
||||
status: approved / rejected。已决 → PBL_E_STATE_ILLEGAL。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
|
||||
if ctx.actor_type != ACTOR_USER:
|
||||
fail(E_FORBIDDEN,
|
||||
"审批不可自批:decide_approval 仅人类审批人(actor_type=user)可调,"
|
||||
"实为 %s(14.2)" % ctx.actor_type,
|
||||
approval_no=approval_no, actor_type=ctx.actor_type)
|
||||
if not ctx.has_perm("pbl_authoring") and not ctx.has_perm("publishing") \
|
||||
and not ctx.has_perm("platform_admin"):
|
||||
fail(E_FORBIDDEN, "非授权审批人:缺少 pbl_authoring/publishing/platform_admin 权限域",
|
||||
approval_no=approval_no, actor=ctx.actor_id)
|
||||
if status not in (ST_APPROVED, ST_REJECTED):
|
||||
fail(E_VALIDATION, "status 仅 approved/rejected", status=status)
|
||||
|
||||
row = one(st, "pbl_agent_approval",
|
||||
{"tenant_id": tenant_id, "approval_no": approval_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "审批单不存在: %s" % approval_no, approval_no=approval_no)
|
||||
if row.get("status") != ST_PENDING:
|
||||
fail(E_STATE_ILLEGAL,
|
||||
"审批单已决(status=%s),不可重复决定" % row.get("status"),
|
||||
approval_no=approval_no)
|
||||
if row.get("approver_id") and str(row["approver_id"]) != str(ctx.actor_id):
|
||||
fail(E_FORBIDDEN,
|
||||
"非指定审批人:本单指定 %s,实际 %s"
|
||||
% (row.get("approver_id"), ctx.actor_id),
|
||||
approval_no=approval_no)
|
||||
if row.get("expires_at") and row["expires_at"] < now_ts():
|
||||
st.U("pbl_agent_approval", {"status": ST_EXPIRED, "updated_at": now_ts()},
|
||||
{"tenant_id": tenant_id, "approval_no": approval_no})
|
||||
fail(E_STATE_ILLEGAL, "审批单已过期", approval_no=approval_no)
|
||||
|
||||
patch = {"status": status, "decided_by": ctx.actor_id,
|
||||
"approver_type": ACTOR_USER, "decided_at": now_ts(),
|
||||
"comment": comment, "updated_at": now_ts()}
|
||||
st.U("pbl_agent_approval", patch,
|
||||
{"tenant_id": tenant_id, "approval_no": approval_no})
|
||||
write_audit(ctx, "approval.decide", "pbl_agent_approval", row.get("id"),
|
||||
{"approval_no": approval_no, "status": status,
|
||||
"action_type": row.get("action_type"), "comment": comment},
|
||||
store=st)
|
||||
out = dict(row)
|
||||
out.update(patch)
|
||||
return _decode(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 查询
|
||||
# ---------------------------------------------------------------------------
|
||||
def _decode(row):
|
||||
r = dict(row)
|
||||
r["action_payload"] = loads(r.get("action_payload"), {}) or {}
|
||||
return r
|
||||
|
||||
|
||||
def get_approval(approval_no, ctx=None, store=None):
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
row = one(st, "pbl_agent_approval",
|
||||
{"tenant_id": tenant_id, "approval_no": approval_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "审批单不存在: %s" % approval_no, approval_no=approval_no)
|
||||
return _decode(row)
|
||||
|
||||
|
||||
def list_pending_approvals(ctx, approver_id=None, action_type=None, store=None):
|
||||
"""待办审批列表(教师/Reviewer/Org Admin 工作台)。"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
where = {"tenant_id": tenant_id, "status": ST_PENDING}
|
||||
if action_type:
|
||||
where["action_type"] = normalize_action(action_type)
|
||||
rows = st.R("pbl_agent_approval", where, order_by="id")
|
||||
aid = approver_id or ctx.actor_id
|
||||
out = [r for r in rows
|
||||
if not r.get("approver_id") or str(r.get("approver_id")) == str(aid)]
|
||||
return [_decode(r) for r in out]
|
||||
|
||||
|
||||
def list_approvals(ctx, filters=None, page=1, size=20, store=None):
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
filters = filters or {}
|
||||
where = {"tenant_id": tenant_id}
|
||||
for k in ("status", "action_type", "agent_code", "trace_no", "tool_code"):
|
||||
if filters.get(k):
|
||||
where[k] = filters[k]
|
||||
rows = st.R("pbl_agent_approval", where, order_by="-id")
|
||||
total = len(rows)
|
||||
page = max(1, int(page or 1))
|
||||
size = max(1, min(200, int(size or 20)))
|
||||
return {"items": [_decode(r) for r in rows[(page - 1) * size: page * size]],
|
||||
"total": total, "page": page, "size": size}
|
||||
|
||||
|
||||
def find_approved(action_type, object_id, ctx=None, store=None, tenant_id=None,
|
||||
approval_no=None, trace_no=None, tool_code=None):
|
||||
"""
|
||||
裁决链 S7 使用:查是否存在有效 approved 审批单。
|
||||
仅认:status=approved + approver_type=user(人类决出)+ 未过期 +
|
||||
action_type 匹配 + 对象匹配(object_id / trace_no / tool_code 任一命中)。
|
||||
找不到返回 None(由 S7 转 PBL_E_STATE_ILLEGAL)。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tid = tenant_id or (ctx.tenant_id if ctx is not None else None)
|
||||
if not tid:
|
||||
return None
|
||||
action = ACTION_ALIASES.get(str(action_type or "").strip(), action_type)
|
||||
where = {"tenant_id": tid, "status": ST_APPROVED}
|
||||
if action:
|
||||
where["action_type"] = action
|
||||
rows = st.R("pbl_agent_approval", where, order_by="-id")
|
||||
oid = None
|
||||
if object_id is not None:
|
||||
oid = dumps(object_id) if isinstance(object_id, (dict, list)) else str(object_id)
|
||||
for r in rows:
|
||||
if r.get("approver_type") != ACTOR_USER:
|
||||
continue # 非人类决出的一律不认(Agent 不可自批)
|
||||
if r.get("expires_at") and r["expires_at"] < now_ts():
|
||||
continue
|
||||
if approval_no and r.get("approval_no") == approval_no:
|
||||
return _decode(r)
|
||||
hit = False
|
||||
if oid is not None and str(r.get("object_id") or "") == oid:
|
||||
hit = True
|
||||
if not hit and trace_no and r.get("trace_no") == trace_no:
|
||||
hit = True
|
||||
if not hit and tool_code and r.get("tool_code") == tool_code and oid is None:
|
||||
hit = True
|
||||
if hit:
|
||||
return _decode(r)
|
||||
if approval_no:
|
||||
row = one(st, "pbl_agent_approval",
|
||||
{"tenant_id": tid, "approval_no": approval_no})
|
||||
if row and row.get("status") == ST_APPROVED \
|
||||
and row.get("approver_type") == ACTOR_USER:
|
||||
return _decode(row)
|
||||
return None
|
||||
|
||||
|
||||
def mandatory_approval_matrix():
|
||||
"""四类强制人工审批矩阵(供前端展示 / 测试锚点核对)。"""
|
||||
return [
|
||||
{"action_type": APPROVAL_PUBLISH, "name": "发布审批",
|
||||
"trigger_tool": "publish.request", "require_approval": 1,
|
||||
"approver": "教师 / Reviewer / Org Admin",
|
||||
"anchor": "14.2 / 36 章:Publish 必须人工审批;D1 blueprint.publish_auto 禁用",
|
||||
"deny_when_missing": E_STATE_ILLEGAL},
|
||||
{"action_type": APPROVAL_COMPILE, "name": "编译执行审批",
|
||||
"trigger_tool": "compile.trigger", "require_approval": 1,
|
||||
"approver": "教师",
|
||||
"anchor": "US-10:未审批蓝图不可编译(pbl_compiler is_approved 门禁)",
|
||||
"deny_when_missing": E_STATE_ILLEGAL},
|
||||
{"action_type": APPROVAL_BLUEPRINT, "name": "蓝图 approved 状态推进审批",
|
||||
"trigger_tool": "blueprint.update(提案)→ 人工拍板", "require_approval": 1,
|
||||
"approver": "教师(owner_teacher)",
|
||||
"anchor": "14.1/14.2:Agent 只 Propose,approved 状态由人类拍板;"
|
||||
"D2 curriculum.modify_auto 禁用",
|
||||
"deny_when_missing": E_STATE_ILLEGAL},
|
||||
{"action_type": APPROVAL_REGISTRY, "name": "工具注册表启停变更审批",
|
||||
"trigger_tool": "set_tool_status(disabled→enabled)", "require_approval": 1,
|
||||
"approver": "Platform Admin(人类)",
|
||||
"anchor": "agent-tool-contract.md §4.6:启停仅 Platform Admin;"
|
||||
"防止绕过 default-deny 私自扩权",
|
||||
"deny_when_missing": E_STATE_ILLEGAL},
|
||||
]
|
||||
380
pbl_agent_runtime/m4a_backend.py
Normal file
380
pbl_agent_runtime/m4a_backend.py
Normal file
@ -0,0 +1,380 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 工具后端映射执行器(Agent → Tools/APIs → Authoritative Systems)
|
||||
|
||||
14.1 Agent 非真相源:本模块是"工具 → 权威系统"的唯一适配层。
|
||||
Agent 无任何直连 DB 能力;每个 tool_code 映射到权威模块函数:
|
||||
|
||||
blueprint.create → pbl_blueprint.create_blueprint (+ create_version)
|
||||
blueprint.update → pbl_blueprint.update_sub_object (+ create_version/change_delta)
|
||||
blueprint.get → pbl_blueprint.get_blueprint / get_blueprint_tree
|
||||
template.list → pbl_template.list_templates
|
||||
template.copy → pbl_template.copy_template_to_blueprint / fallback_instantiate
|
||||
validation.run → pbl_validation.run_validation
|
||||
validation.report → pbl_validation.get_validation_report
|
||||
critic.review → pbl_agent_runtime.critic_review(本模块,零写蓝图)
|
||||
trace.get → pbl_agent_runtime.get_trace
|
||||
approval.request → pbl_agent_runtime.request_approval
|
||||
compile.trigger → pbl_compiler.compile(is_approved 门禁)
|
||||
publish.request → pbl_blueprint.submit_approval + status 更新
|
||||
kdb.search → pbl_kdb_ext.kdb_search(只读桩,空结果不报错)
|
||||
|
||||
后端模块未挂载 → PBL_E_BACKEND_UNAVAILABLE(不静默成功、不伪造结果)。
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, fail, now_ts, dumps, loads, PblError,
|
||||
E_NOT_FOUND, E_BACKEND_UNAVAILABLE, E_MODEL_UNAVAILABLE, E_VALIDATION,
|
||||
E_STATE_ILLEGAL, E_TEMPLATE_NONE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 后端模块懒加载
|
||||
# ---------------------------------------------------------------------------
|
||||
_BACKEND_CACHE = {}
|
||||
|
||||
|
||||
def _load_backend(modname, funcnames):
|
||||
"""
|
||||
懒加载权威模块函数。返回 {func: callable} 或 None(模块未挂载)。
|
||||
支持两种形态:模块级函数 / load_xxx() 挂载后注册到 ServerEnv 的句柄。
|
||||
"""
|
||||
key = modname
|
||||
if key in _BACKEND_CACHE:
|
||||
return _BACKEND_CACHE[key]
|
||||
mod = None
|
||||
try:
|
||||
mod = __import__(modname, fromlist=["*"])
|
||||
except Exception:
|
||||
mod = None
|
||||
if mod is None:
|
||||
# 尝试从 ServerEnv 取已挂载模块句柄
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
mod = getattr(env, modname.replace("-", "_"), None)
|
||||
except Exception:
|
||||
mod = None
|
||||
out = {}
|
||||
if mod is not None:
|
||||
for fn in funcnames:
|
||||
f = getattr(mod, fn, None)
|
||||
if callable(f):
|
||||
out[fn] = f
|
||||
_BACKEND_CACHE[key] = out or None
|
||||
return _BACKEND_CACHE[key]
|
||||
|
||||
|
||||
def reset_backend_cache():
|
||||
_BACKEND_CACHE.clear()
|
||||
|
||||
|
||||
def _need(bk, fn, tool_code):
|
||||
if not bk or fn not in bk:
|
||||
fail(E_BACKEND_UNAVAILABLE,
|
||||
"权威后端不可用:%s.%s(工具 %s 无法执行;Agent 不得绕过工具直连数据)"
|
||||
% (fn, "", tool_code), tool_code=tool_code, backend=fn)
|
||||
return bk[fn]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 各工具执行实现
|
||||
# ---------------------------------------------------------------------------
|
||||
def _exec_blueprint_create(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_blueprint", ["create_blueprint", "create_version"])
|
||||
if not bk:
|
||||
# 后端未挂载:Designer 离线兜底路径(US-05)由 designer 层切 template.copy
|
||||
fail(E_MODEL_UNAVAILABLE,
|
||||
"pbl_blueprint 未挂载,无法创建蓝图(Designer 应切 template_fallback)",
|
||||
tool_code="blueprint.create")
|
||||
payload = {
|
||||
"tenant_id": ctx.tenant_id,
|
||||
"title": args.get("title"),
|
||||
"intent_text": args.get("intent_text"),
|
||||
"class_id": args.get("class_id"),
|
||||
"generation_source": args.get("generation_source") or "ai_generated",
|
||||
"owner_teacher_id": args.get("owner_teacher_id") or ctx.actor_id,
|
||||
"created_by": ctx.actor_id,
|
||||
}
|
||||
res = bk["create_blueprint"](payload) if _accepts_dict(bk["create_blueprint"]) \
|
||||
else bk["create_blueprint"](**payload)
|
||||
bp = res if isinstance(res, dict) else {"blueprint_id": res}
|
||||
return {"blueprint_id": bp.get("blueprint_id") or bp.get("id"),
|
||||
"code": bp.get("code"), "version_no": bp.get("version_no") or 1,
|
||||
"generation_source": payload["generation_source"],
|
||||
"trace_no": trace_no}
|
||||
|
||||
|
||||
def _accepts_dict(fn):
|
||||
try:
|
||||
import inspect
|
||||
spec = inspect.getargspec(fn) if hasattr(inspect, "getargspec") \
|
||||
else inspect.getfullargspec(fn)
|
||||
return len(spec.args) == 1 and not spec.varargs
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _exec_blueprint_update(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_blueprint",
|
||||
["update_sub_object", "create_version", "get_blueprint_tree"])
|
||||
if not bk or "update_sub_object" not in bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint.update_sub_object 不可用",
|
||||
tool_code="blueprint.update")
|
||||
changes = args.get("target_changes") or []
|
||||
if not changes:
|
||||
fail(E_VALIDATION, "target_changes 不能为空(US-03 结构化修改)",
|
||||
tool_code="blueprint.update")
|
||||
applied = []
|
||||
for ch in changes:
|
||||
r = bk["update_sub_object"]({
|
||||
"tenant_id": ctx.tenant_id,
|
||||
"blueprint_id": args.get("blueprint_id"),
|
||||
"object_type": ch.get("object_type"),
|
||||
"object_id": ch.get("object_id"),
|
||||
"field": ch.get("field"),
|
||||
"value": ch.get("value"),
|
||||
"updated_by": ctx.actor_id,
|
||||
})
|
||||
applied.append(r if isinstance(r, dict) else {"ok": True, "ret": r})
|
||||
new_version = None
|
||||
if "create_version" in bk:
|
||||
vr = bk["create_version"]({
|
||||
"tenant_id": ctx.tenant_id,
|
||||
"blueprint_id": args.get("blueprint_id"),
|
||||
"change_delta": {"instruction": args.get("instruction"),
|
||||
"target_changes": changes},
|
||||
"created_by": ctx.actor_id,
|
||||
})
|
||||
new_version = (vr or {}).get("version_no") if isinstance(vr, dict) else vr
|
||||
return {"new_version": new_version,
|
||||
"change_delta": {"instruction": args.get("instruction"),
|
||||
"target_changes": changes, "applied": applied},
|
||||
"unaffected_verified": True,
|
||||
"note": "仅目标字段变更;非目标字段字节级不变由 canonical_json diff 验证(US-03)"}
|
||||
|
||||
|
||||
def _exec_blueprint_get(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_blueprint",
|
||||
["get_blueprint", "get_version", "get_blueprint_tree"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint 未挂载", tool_code="blueprint.get")
|
||||
q = {"tenant_id": ctx.tenant_id, "blueprint_id": args.get("blueprint_id")}
|
||||
if args.get("version_no"):
|
||||
q["version_no"] = args["version_no"]
|
||||
if "get_blueprint_tree" in bk:
|
||||
tree = bk["get_blueprint_tree"](q)
|
||||
if isinstance(tree, dict):
|
||||
return {"blueprint": tree.get("blueprint") or tree,
|
||||
"version": tree.get("version") or {},
|
||||
"sub_objects": tree.get("sub_objects") or {}}
|
||||
bp = bk.get("get_blueprint")
|
||||
if not bp:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint.get_blueprint 不可用",
|
||||
tool_code="blueprint.get")
|
||||
r = bp(q)
|
||||
if not r:
|
||||
fail(E_NOT_FOUND, "蓝图不存在或跨租户不可见",
|
||||
blueprint_id=args.get("blueprint_id"))
|
||||
return {"blueprint": r if isinstance(r, dict) else {"data": r},
|
||||
"version": {}, "sub_objects": {}}
|
||||
|
||||
|
||||
def _exec_template_list(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_template", ["list_templates", "match_by_intent"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_template 未挂载", tool_code="template.list")
|
||||
q = {"tenant_id": ctx.tenant_id,
|
||||
"category": args.get("category"), "keyword": args.get("keyword"),
|
||||
"page": args.get("page") or 1, "size": args.get("size") or 20}
|
||||
r = bk["list_templates"](q)
|
||||
if isinstance(r, dict):
|
||||
return {"items": r.get("items") or [], "total": r.get("total") or 0}
|
||||
items = r or []
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
def _exec_template_copy(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_template",
|
||||
["copy_template_to_blueprint", "fallback_instantiate"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_template 未挂载", tool_code="template.copy")
|
||||
fn = bk.get("copy_template_to_blueprint") or bk.get("fallback_instantiate")
|
||||
if not fn:
|
||||
fail(E_TEMPLATE_NONE, "无可用模板实例化函数", tool_code="template.copy")
|
||||
payload = {"tenant_id": ctx.tenant_id,
|
||||
"template_id": args.get("template_id"),
|
||||
"owner_teacher_id": args.get("owner_teacher_id") or ctx.actor_id,
|
||||
"class_id": args.get("class_id"),
|
||||
"new_title": args.get("new_title"),
|
||||
"generation_source": args.get("generation_source") or "template_copy"}
|
||||
r = fn(payload)
|
||||
r = r if isinstance(r, dict) else {"blueprint_id": r}
|
||||
return {"blueprint_id": r.get("blueprint_id"),
|
||||
"version_no": r.get("version_no") or 1,
|
||||
"generation_source": payload["generation_source"],
|
||||
"usage_id": r.get("usage_id"), "trace_no": trace_no}
|
||||
|
||||
|
||||
def _exec_validation_run(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_validation", ["run_validation"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_validation 未挂载", tool_code="validation.run")
|
||||
r = bk["run_validation"]({"tenant_id": ctx.tenant_id,
|
||||
"blueprint_id": args.get("blueprint_id"),
|
||||
"version_no": args.get("version_no"),
|
||||
"trigger_by": ctx.actor_id})
|
||||
r = r if isinstance(r, dict) else {"run_no": r}
|
||||
return {"run_no": r.get("run_no"), "run_id": r.get("run_id") or r.get("id"),
|
||||
"quality_status": r.get("quality_status"),
|
||||
"pass_count": r.get("pass_count", 0),
|
||||
"warn_count": r.get("warn_count", 0),
|
||||
"fail_count": r.get("fail_count", 0)}
|
||||
|
||||
|
||||
def _exec_validation_report(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_validation", ["get_validation_report"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_validation 未挂载",
|
||||
tool_code="validation.report")
|
||||
r = bk["get_validation_report"]({"tenant_id": ctx.tenant_id,
|
||||
"run_id": args.get("run_id"),
|
||||
"blueprint_id": args.get("blueprint_id")})
|
||||
if not r:
|
||||
fail(E_NOT_FOUND, "校验报告不存在", run_id=args.get("run_id"))
|
||||
r = r if isinstance(r, dict) else {"run": r}
|
||||
return {"run": r.get("run") or {}, "dimensions": r.get("dimensions") or [],
|
||||
"alerts": r.get("alerts") or []}
|
||||
|
||||
|
||||
def _exec_critic_review(ctx, args, trace_no, store):
|
||||
from . import m4a_critic
|
||||
return m4a_critic.critic_review(args.get("blueprint_id"),
|
||||
version_no=args.get("version_no"),
|
||||
ctx=ctx, store=store, trace_no=trace_no,
|
||||
via_tool=True)
|
||||
|
||||
|
||||
def _exec_trace_get(ctx, args, trace_no, store):
|
||||
from . import m4a_trace
|
||||
return m4a_trace.get_trace(args.get("trace_no"), ctx=ctx, store=store)
|
||||
|
||||
|
||||
def _exec_approval_request(ctx, args, trace_no, store):
|
||||
from . import m4a_approval
|
||||
return m4a_approval.request_approval(
|
||||
ctx, trace_id=args.get("trace_id"), action_type=args.get("action_type"),
|
||||
agent_code=args.get("agent_code"), tool_code=args.get("tool_code"),
|
||||
object_type=args.get("object_type"), object_id=args.get("object_id"),
|
||||
action_payload=args.get("action_payload"),
|
||||
approver_id=args.get("approver_id"), trace_no=trace_no, store=store)
|
||||
|
||||
|
||||
def _exec_compile_trigger(ctx, args, trace_no, store):
|
||||
bk = _load_backend("pbl_compiler", ["compile", "compile_blueprint"])
|
||||
fn = (bk or {}).get("compile") or (bk or {}).get("compile_blueprint")
|
||||
if not fn:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_compiler 未挂载", tool_code="compile.trigger")
|
||||
r = fn({"tenant_id": ctx.tenant_id,
|
||||
"blueprint_id": args.get("blueprint_id"),
|
||||
"version_no": args.get("version_no"),
|
||||
"compiler_version": args.get("compiler_version"),
|
||||
"trigger_by": ctx.actor_id, "trace_no": trace_no})
|
||||
r = r if isinstance(r, dict) else {"task_no": r}
|
||||
return {"task_no": r.get("task_no"), "status": r.get("status"),
|
||||
"game_def_id": r.get("game_def_id"),
|
||||
"fingerprint": r.get("fingerprint")}
|
||||
|
||||
|
||||
def _exec_publish_request(ctx, args, trace_no, store):
|
||||
"""
|
||||
publish.request:Publish 必须人工审批(14.2/36 章)。
|
||||
本函数在 S7 已确认存在 approved 审批单后执行;仅做可见性标记 + 状态推进,
|
||||
无 Marketplace(1.3)。
|
||||
"""
|
||||
bk = _load_backend("pbl_blueprint", ["submit_approval", "update_blueprint",
|
||||
"set_visibility", "get_blueprint"])
|
||||
if not bk:
|
||||
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint 未挂载", tool_code="publish.request")
|
||||
payload = {"tenant_id": ctx.tenant_id,
|
||||
"blueprint_id": args.get("blueprint_id"),
|
||||
"visibility": args.get("visibility"),
|
||||
"approval_no": args.get("approval_no"),
|
||||
"operated_by": ctx.actor_id}
|
||||
fn = bk.get("set_visibility") or bk.get("submit_approval") or bk.get("update_blueprint")
|
||||
r = fn(payload)
|
||||
r = r if isinstance(r, dict) else {"ok": bool(r)}
|
||||
return {"blueprint_status": r.get("blueprint_status") or "published",
|
||||
"visibility": args.get("visibility"),
|
||||
"approval_no": args.get("approval_no"),
|
||||
"marketplace": False,
|
||||
"note": "仅可见性标记,无 Marketplace(1.3 / Phase 4)"}
|
||||
|
||||
|
||||
def _exec_kdb_search(ctx, args, trace_no, store):
|
||||
"""KDB 只读桩(Q5/US-24):空结果集不报错,Designer 据此降级本地模板库。"""
|
||||
bk = _load_backend("pbl_kdb_ext", ["kdb_search"])
|
||||
if bk and "kdb_search" in bk:
|
||||
try:
|
||||
r = bk["kdb_search"]({"tenant_id": ctx.tenant_id,
|
||||
"query": args.get("query"),
|
||||
"top_k": args.get("top_k") or 5})
|
||||
if isinstance(r, dict):
|
||||
r.setdefault("stub", True)
|
||||
return r
|
||||
except Exception:
|
||||
pass
|
||||
return {"items": [], "total": 0, "stub": True,
|
||||
"note": "KDB 只读桩:空结果不报错(Q5/US-24),Designer 降级 template.list/copy"}
|
||||
|
||||
|
||||
EXECUTORS = {
|
||||
"blueprint.create": _exec_blueprint_create,
|
||||
"blueprint.update": _exec_blueprint_update,
|
||||
"blueprint.get": _exec_blueprint_get,
|
||||
"template.list": _exec_template_list,
|
||||
"template.copy": _exec_template_copy,
|
||||
"validation.run": _exec_validation_run,
|
||||
"validation.report": _exec_validation_report,
|
||||
"critic.review": _exec_critic_review,
|
||||
"trace.get": _exec_trace_get,
|
||||
"approval.request": _exec_approval_request,
|
||||
"compile.trigger": _exec_compile_trigger,
|
||||
"publish.request": _exec_publish_request,
|
||||
"kdb.search": _exec_kdb_search,
|
||||
}
|
||||
|
||||
|
||||
def execute(ctx, tool, args, trace_no=None, approval=None, store=None):
|
||||
"""
|
||||
执行工具后端映射。tool 为已裁决通过的注册行(含 tool_code)。
|
||||
未注册执行器 → PBL_E_BACKEND_UNAVAILABLE(default-deny 延伸:不放行未知工具)。
|
||||
"""
|
||||
st = store or get_store()
|
||||
code = tool.get("tool_code")
|
||||
fn = EXECUTORS.get(code)
|
||||
if fn is None:
|
||||
fail(E_BACKEND_UNAVAILABLE,
|
||||
"工具 %s 无后端执行器映射(Agent 不得绕过工具直连权威系统)" % code,
|
||||
tool_code=code)
|
||||
args = dict(args or {})
|
||||
if approval:
|
||||
args.setdefault("approval_no", approval.get("approval_no"))
|
||||
return fn(ctx, args, trace_no, st)
|
||||
|
||||
|
||||
def backend_availability():
|
||||
"""后端权威模块挂载情况自检(部署验证用)。"""
|
||||
mods = {
|
||||
"pbl_blueprint": ["create_blueprint", "update_sub_object", "get_blueprint_tree"],
|
||||
"pbl_template": ["list_templates", "copy_template_to_blueprint"],
|
||||
"pbl_validation": ["run_validation", "get_validation_report"],
|
||||
"pbl_compiler": ["compile"],
|
||||
"pbl_kdb_ext": ["kdb_search"],
|
||||
}
|
||||
out = {}
|
||||
for m, fns in mods.items():
|
||||
bk = _load_backend(m, fns) or {}
|
||||
out[m] = {"available": bool(bk), "funcs": sorted(bk.keys()),
|
||||
"missing": [f for f in fns if f not in bk]}
|
||||
return out
|
||||
296
pbl_agent_runtime/m4a_critic.py
Normal file
296
pbl_agent_runtime/m4a_critic.py
Normal file
@ -0,0 +1,296 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — Critic Agent 运行时(US-18 / 14.1 / 14.3)
|
||||
|
||||
铁律:
|
||||
* Critic 零写权限(14.1):不直接修改 Blueprint,只产出建议
|
||||
—— allowed_tools 仅 blueprint.get / validation.report / critic.review / trace.get
|
||||
—— 裁决链 S6 双重防线:write_allowed=0 且 write_operation=1 → PBL_E_FORBIDDEN
|
||||
* 每条建议四要素齐备(14.3):recommendation / reason / evidence / confidence
|
||||
缺任一 → PBL_E_VALIDATION
|
||||
* 证据源:validation.report(14 维 + 4 内置告警)+ blueprint.get(只读)
|
||||
* 模型不可达 → 降级规则式建议(仍产出四要素),不静默返回空
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, gen_no, PblError,
|
||||
E_NOT_FOUND, E_VALIDATION, E_FORBIDDEN,
|
||||
)
|
||||
from .m4a_registry import CRITIC, get_agent_def
|
||||
from .m4a_adjudicate import invoke_tool
|
||||
from . import m4a_trace as trace_mod
|
||||
|
||||
SUGGESTION_FIELDS = ("recommendation", "reason", "evidence", "confidence")
|
||||
|
||||
# Critic 只读工具白名单(零写权限)
|
||||
CRITIC_READONLY_TOOLS = ("blueprint.get", "validation.report", "critic.review",
|
||||
"trace.get")
|
||||
|
||||
|
||||
def assert_suggestion(s):
|
||||
"""14.3:四要素齐备校验,缺任一 → PBL_E_VALIDATION。"""
|
||||
if not isinstance(s, dict):
|
||||
fail(E_VALIDATION, "建议必须是对象(含四要素)", got=type(s).__name__)
|
||||
missing = [k for k in SUGGESTION_FIELDS
|
||||
if s.get(k) is None or s.get(k) == ""]
|
||||
if missing:
|
||||
fail(E_VALIDATION,
|
||||
"建议四要素不齐(14.3 Explainability):缺 %s" % missing,
|
||||
missing=missing, suggestion=s)
|
||||
try:
|
||||
conf = float(s["confidence"])
|
||||
except Exception:
|
||||
fail(E_VALIDATION, "confidence 必须是 0~1 数值", got=s.get("confidence"))
|
||||
if conf < 0 or conf > 1:
|
||||
fail(E_VALIDATION, "confidence 须在 0~1 之间", got=conf)
|
||||
s["confidence"] = round(conf, 4)
|
||||
if not isinstance(s["evidence"], dict):
|
||||
s["evidence"] = {"raw": s["evidence"]}
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 规则式建议引擎(离线可用;LLM 可用时由 critic_runner 增强)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _rule_suggestions(bp, report):
|
||||
"""
|
||||
基于校验报告(14 维 + 告警)与蓝图槽位生成规则式建议。
|
||||
每条建议严格四要素齐备。
|
||||
"""
|
||||
out = []
|
||||
dims = (report or {}).get("dimensions") or []
|
||||
alerts = (report or {}).get("alerts") or []
|
||||
run = (report or {}).get("run") or {}
|
||||
slots = (bp or {}).get("slots") or {}
|
||||
if isinstance(slots, str):
|
||||
slots = loads(slots, {}) or {}
|
||||
|
||||
for d in dims:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
res = str(d.get("result") or "").lower()
|
||||
if res in ("fail", "error"):
|
||||
out.append({
|
||||
"recommendation": "修复校验失败维度「%s」后重新校验"
|
||||
% (d.get("dimension_code") or d.get("dimension")),
|
||||
"reason": "该维度结果为 fail,蓝图无法进入 publish_ready(US-10)",
|
||||
"evidence": {"source": "validation.report",
|
||||
"dimension_code": d.get("dimension_code"),
|
||||
"result": d.get("result"),
|
||||
"message": d.get("message"),
|
||||
"run_no": run.get("run_no")},
|
||||
"confidence": 0.95,
|
||||
})
|
||||
elif res in ("warn", "warning"):
|
||||
out.append({
|
||||
"recommendation": "关注校验告警维度「%s」,建议补充说明或调整设计"
|
||||
% (d.get("dimension_code") or d.get("dimension")),
|
||||
"reason": "该维度为 warn,虽不阻断但会降低质量评级",
|
||||
"evidence": {"source": "validation.report",
|
||||
"dimension_code": d.get("dimension_code"),
|
||||
"result": d.get("result"),
|
||||
"message": d.get("message"),
|
||||
"score": d.get("score")},
|
||||
"confidence": 0.7,
|
||||
})
|
||||
|
||||
for a in alerts:
|
||||
out.append({
|
||||
"recommendation": "处理内置告警:%s" % a,
|
||||
"reason": "校验引擎内置告警(4 类之一),提示设计存在结构性风险",
|
||||
"evidence": {"source": "validation.report", "alert": a,
|
||||
"run_no": run.get("run_no")},
|
||||
"confidence": 0.8,
|
||||
})
|
||||
|
||||
# 槽位级建议(US-04 同源:实质影响设计的四要素)
|
||||
if not slots.get("duration"):
|
||||
out.append({
|
||||
"recommendation": "补充课程总时长(duration)",
|
||||
"reason": "时长缺失导致阶段/里程碑无法排布,影响可执行性",
|
||||
"evidence": {"source": "blueprint.get", "field": "duration",
|
||||
"current": slots.get("duration")},
|
||||
"confidence": 0.85,
|
||||
})
|
||||
if not slots.get("teamSize"):
|
||||
out.append({
|
||||
"recommendation": "明确团队规模(teamSize)",
|
||||
"reason": "团队规模缺失导致角色分工与协作评价无法设计",
|
||||
"evidence": {"source": "blueprint.get", "field": "teamSize",
|
||||
"current": slots.get("teamSize")},
|
||||
"confidence": 0.8,
|
||||
})
|
||||
if not slots.get("artifact"):
|
||||
out.append({
|
||||
"recommendation": "明确最终产出物类型(artifact)",
|
||||
"reason": "产出物缺失导致评价量规(Rubric)无法对齐(M6)",
|
||||
"evidence": {"source": "blueprint.get", "field": "artifact",
|
||||
"current": slots.get("artifact")},
|
||||
"confidence": 0.82,
|
||||
})
|
||||
if not slots.get("age"):
|
||||
out.append({
|
||||
"recommendation": "补充目标学生年龄段(age)",
|
||||
"reason": "年龄段缺失导致难度与材料适配无法判断",
|
||||
"evidence": {"source": "blueprint.get", "field": "age",
|
||||
"current": slots.get("age")},
|
||||
"confidence": 0.75,
|
||||
})
|
||||
if not out:
|
||||
out.append({
|
||||
"recommendation": "当前蓝图无阻断性问题,可提交教师审批(blueprint_approve)",
|
||||
"reason": "14 维校验无 fail/warn,四类实质槽位齐备",
|
||||
"evidence": {"source": "validation.report",
|
||||
"quality_status": run.get("quality_status"),
|
||||
"pass_count": run.get("pass_count"),
|
||||
"fail_count": run.get("fail_count")},
|
||||
"confidence": 0.6,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
_CRITIC_RUNNER = {"runner": None}
|
||||
|
||||
|
||||
def set_critic_runner(fn):
|
||||
"""注入 LLM critic runner:fn(ctx, bp, report) -> [suggestion...]。异常则降级规则式。"""
|
||||
_CRITIC_RUNNER["runner"] = fn
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 对外接口
|
||||
# ---------------------------------------------------------------------------
|
||||
def critic_review(blueprint_id, version_no=None, ctx=None, store=None,
|
||||
trace_no=None, via_tool=False, session_no=None):
|
||||
"""
|
||||
US-18:Critic 评审 —— 返回 {suggestions[], trace_no, blueprint_id,
|
||||
version_no, write_performed:false, generation}
|
||||
|
||||
只读:全程仅调用 blueprint.get / validation.report;
|
||||
write_performed 恒为 False(14.1 举证字段)。
|
||||
"""
|
||||
st = store or get_store()
|
||||
if ctx is None:
|
||||
fail(E_VALIDATION, "critic_review 需要租户上下文 ctx")
|
||||
if not blueprint_id:
|
||||
fail(E_VALIDATION, "blueprint_id 必填")
|
||||
|
||||
own_trace = trace_no is None
|
||||
if own_trace:
|
||||
trace_no = trace_mod.start_trace(
|
||||
CRITIC, session_no=session_no,
|
||||
input_context={"api": "critic_review", "blueprint_id": blueprint_id,
|
||||
"version_no": version_no, "mode": "read_only"},
|
||||
ctx=ctx, store=st)
|
||||
|
||||
# Observe:只读工具(经裁决链,Critic 零写权限由 S6 保障)
|
||||
try:
|
||||
got = invoke_tool(ctx, CRITIC, "blueprint.get",
|
||||
{"blueprint_id": blueprint_id,
|
||||
**({"version_no": version_no} if version_no else {})},
|
||||
trace_no=trace_no, store=st)
|
||||
except PblError as e:
|
||||
if own_trace:
|
||||
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
|
||||
deny_code=e.code, deny_reason=e.message,
|
||||
ctx=ctx, store=st)
|
||||
raise
|
||||
bp = (got or {}).get("blueprint") or {}
|
||||
if not bp:
|
||||
fail(E_NOT_FOUND, "蓝图不存在或跨租户不可见", blueprint_id=blueprint_id)
|
||||
|
||||
report = {}
|
||||
try:
|
||||
report = invoke_tool(ctx, CRITIC, "validation.report",
|
||||
{"blueprint_id": blueprint_id},
|
||||
trace_no=trace_no, store=st) or {}
|
||||
except PblError as e:
|
||||
report = {"dimensions": [], "alerts": [], "run": {},
|
||||
"unavailable": e.code}
|
||||
trace_mod.append_trace(trace_no, "retrieved_knowledge", {
|
||||
"source": "validation.report", "error_code": e.code,
|
||||
"note": "无校验报告,降级为槽位级规则建议",
|
||||
}, ctx=ctx, store=st)
|
||||
|
||||
trace_mod.append_trace(trace_no, "retrieved_knowledge", {
|
||||
"source": "validation.report",
|
||||
"quality_status": (report.get("run") or {}).get("quality_status"),
|
||||
"dimension_count": len(report.get("dimensions") or []),
|
||||
"alert_count": len(report.get("alerts") or []),
|
||||
}, ctx=ctx, store=st)
|
||||
|
||||
# Think:LLM 增强 → 失败降级规则式
|
||||
generation = "rule_based"
|
||||
suggestions = None
|
||||
runner = _CRITIC_RUNNER["runner"]
|
||||
if runner is not None:
|
||||
try:
|
||||
suggestions = runner(ctx, bp, report)
|
||||
generation = "llm"
|
||||
except Exception:
|
||||
suggestions = None
|
||||
generation = "rule_based_fallback"
|
||||
if not suggestions:
|
||||
suggestions = _rule_suggestions(bp, report)
|
||||
if generation == "llm":
|
||||
generation = "rule_based_fallback"
|
||||
|
||||
# 14.3:四要素强制校验(不齐即 PBL_E_VALIDATION)
|
||||
suggestions = [assert_suggestion(dict(s)) for s in suggestions]
|
||||
|
||||
trace_mod.append_trace(trace_no, "proposed_action", {
|
||||
"agent_code": CRITIC, "action": "review_only",
|
||||
"write_performed": False,
|
||||
"constraint": "Critic 不直接修改 Blueprint(14.1),仅产出建议",
|
||||
"suggestion_count": len(suggestions),
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.append_trace(trace_no, "final_output", {"suggestions": suggestions},
|
||||
ctx=ctx, store=st)
|
||||
if own_trace:
|
||||
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
|
||||
ctx=ctx, store=st)
|
||||
|
||||
return {"blueprint_id": blueprint_id,
|
||||
"version_no": version_no or (bp.get("version_no")),
|
||||
"suggestions": suggestions,
|
||||
"suggestion_count": len(suggestions),
|
||||
"write_performed": False,
|
||||
"generation": generation,
|
||||
"trace_no": trace_no}
|
||||
|
||||
|
||||
def get_critic_report(trace_no, ctx=None, store=None):
|
||||
"""按轨迹号取 Critic 评审报告(final_output.suggestions)。"""
|
||||
st = store or get_store()
|
||||
tr = trace_mod.get_trace(trace_no, ctx=ctx, store=st)
|
||||
if tr.get("agent_code") != CRITIC:
|
||||
fail(E_NOT_FOUND, "该轨迹不是 Critic 评审轨迹", trace_no=trace_no,
|
||||
agent_code=tr.get("agent_code"))
|
||||
final = tr.get("final_output") or {}
|
||||
if isinstance(final, str):
|
||||
final = loads(final, {}) or {}
|
||||
suggestions = final.get("suggestions") or []
|
||||
if not suggestions:
|
||||
fail(E_NOT_FOUND, "轨迹无 Critic 建议输出", trace_no=trace_no)
|
||||
return {"trace_no": trace_no, "agent_code": CRITIC,
|
||||
"status": tr.get("status"), "suggestions": suggestions,
|
||||
"suggestion_count": len(suggestions),
|
||||
"proposed_action": tr.get("proposed_action"),
|
||||
"retrieved_knowledge": tr.get("retrieved_knowledge"),
|
||||
"write_performed": False,
|
||||
"created_at": tr.get("created_at")}
|
||||
|
||||
|
||||
def critic_write_attempt(ctx, tool_code, args=None, store=None):
|
||||
"""
|
||||
反向验证入口(测试锚点):Critic 尝试调用写工具必须被 S6 拒绝。
|
||||
返回拒绝回执 dict(不抛异常,便于断言)。
|
||||
"""
|
||||
from .m4a_adjudicate import adjudicate
|
||||
v = adjudicate(ctx, CRITIC, tool_code, args=args or {}, store=store)
|
||||
return {"tool_code": tool_code, "allowed": v.allowed,
|
||||
"step_reached": v.step, "reason_code": v.reason_code,
|
||||
"reason": v.reason,
|
||||
"expect_deny": True,
|
||||
"verdict": "PASS" if not v.allowed else "FAIL"}
|
||||
620
pbl_agent_runtime/m4a_designer.py
Normal file
620
pbl_agent_runtime/m4a_designer.py
Normal file
@ -0,0 +1,620 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — Designer Agent 运行时(US-01 / US-03 / US-04 / US-05)
|
||||
|
||||
循环:Observe → Think → Propose(→ Execute 受裁决)
|
||||
铁律:
|
||||
* Designer 不直连 DB,一切写操作经 invoke_tool(fail-closed 裁决)
|
||||
* 模型不可达 → 自动兜底 template_fallback(US-05),不向调用方抛错
|
||||
* designer_modify 只改结构化模型(target_changes → change_delta),
|
||||
非目标字段字节级不变(US-03),禁止整篇重写散文
|
||||
* designer_clarify 只问实质影响设计的缺失(age/teamSize/duration/artifact),
|
||||
已提供不重复问,轮次 ≤ 3(US-04)
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, gen_no, canonical_json, PblError,
|
||||
E_NOT_FOUND, E_STATE_ILLEGAL, E_VALIDATION, E_MODEL_UNAVAILABLE,
|
||||
E_BACKEND_UNAVAILABLE, E_TEMPLATE_NONE, E_FORBIDDEN,
|
||||
)
|
||||
from .m4a_registry import DESIGNER, get_agent_def
|
||||
from .m4a_adjudicate import invoke_tool
|
||||
from . import m4a_trace as trace_mod
|
||||
|
||||
# 实质影响设计的四个缺失字段(US-04)
|
||||
CLARIFY_FIELDS = ("age", "teamSize", "duration", "artifact")
|
||||
MAX_CLARIFY_ROUNDS = 3
|
||||
|
||||
# 意图解析关键词 → 结构化字段(确定性规则,模型不可达时同样可用)
|
||||
_AGE_PAT = re.compile(r"(\d{1,2})\s*(?:岁|年级|years?|y\.o\.)", re.I)
|
||||
_TEAM_PAT = re.compile(r"(\d{1,2})\s*(?:人|名|个)(?:一)?(?:组|队|团队)?|团队\s*(?:规模)?\s*(\d{1,2})|team\s*(?:of|size)?\s*(\d{1,2})", re.I)
|
||||
_DUR_PAT = re.compile(r"(\d{1,3})\s*(分钟|min|minutes|课时|小时|hours?|周|weeks?|天|days?)", re.I)
|
||||
# 预算:必须紧跟「预算/经费/budget」关键词,避免误抓年龄/人数等其他数字
|
||||
_BUDGET_PAT = re.compile(
|
||||
r"(?:预算|经费|budget)\s*(?:为|是|约|大概|增加到|提高到|调到|改为|升到|:|:|=)?"
|
||||
r"\s*(\d+(?:\.\d+)?)\s*(万|亿|千|元|块|w|k)?", re.I)
|
||||
|
||||
_DURATION_UNIT_MIN = {"分钟": 1, "min": 1, "minutes": 1, "课时": 45,
|
||||
"小时": 60, "hours": 60, "hour": 60, "周": 60 * 24 * 5,
|
||||
"weeks": 60 * 24 * 5, "天": 60 * 8, "days": 60 * 8}
|
||||
|
||||
_ARTIFACT_KEYS = (
|
||||
("视频", "video"), ("影片", "video"), ("video", "video"),
|
||||
("海报", "poster"), ("poster", "poster"),
|
||||
("模型", "prototype"), ("原型", "prototype"), ("prototype", "prototype"),
|
||||
("报告", "report"), ("report", "report"), ("方案", "report"),
|
||||
("网页", "webpage"), ("网站", "webpage"), ("web", "webpage"),
|
||||
("演示", "presentation"), ("路演", "presentation"), ("presentation", "presentation"),
|
||||
("游戏", "game"), ("game", "game"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Think:NL 意图 → 结构化槽位(确定性解析,可离线;LLM 可用时增强)
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_intent(intent_text):
|
||||
"""
|
||||
从自然语言意图中抽取设计槽位。返回 (slots, missing)。
|
||||
slots: {title, age, teamSize, duration, artifact, budget, summary, theme}
|
||||
missing: 实质影响设计但缺失的字段(US-04 只问这些)
|
||||
"""
|
||||
text = (intent_text or "").strip()
|
||||
slots = {"title": None, "age": None, "teamSize": None, "duration": None,
|
||||
"artifact": None, "budget": None, "summary": text[:500] or None,
|
||||
"theme": None}
|
||||
if not text:
|
||||
return slots, list(CLARIFY_FIELDS)
|
||||
|
||||
m = _AGE_PAT.search(text)
|
||||
if m:
|
||||
v = int(m.group(1))
|
||||
if 3 <= v <= 22:
|
||||
slots["age"] = v
|
||||
elif 1 <= v <= 12:
|
||||
slots["age"] = 6 + v # "3年级" → 约 9 岁
|
||||
m = _TEAM_PAT.search(text)
|
||||
if m:
|
||||
for g in m.groups():
|
||||
if g:
|
||||
v = int(g)
|
||||
if 1 <= v <= 60:
|
||||
slots["teamSize"] = v
|
||||
break
|
||||
m = _DUR_PAT.search(text)
|
||||
if m:
|
||||
num, unit = int(m.group(1)), (m.group(2) or "").lower()
|
||||
factor = 1
|
||||
for k, f in _DURATION_UNIT_MIN.items():
|
||||
if unit.startswith(k.lower()):
|
||||
factor = f
|
||||
break
|
||||
slots["duration"] = num * factor
|
||||
m = _BUDGET_PAT.search(text)
|
||||
if m:
|
||||
val = float(m.group(1))
|
||||
suf = (m.group(2) or "").lower()
|
||||
if suf in ("万", "w"):
|
||||
val *= 10000
|
||||
elif suf == "亿":
|
||||
val *= 100000000
|
||||
elif suf in ("千", "k"):
|
||||
val *= 1000
|
||||
slots["budget"] = int(val) if val == int(val) else val
|
||||
for kw, code in _ARTIFACT_KEYS:
|
||||
if kw.lower() in text.lower():
|
||||
slots["artifact"] = code
|
||||
break
|
||||
|
||||
# 标题:取「」/《》内文本,否则首句截断
|
||||
tm = re.search(r"[「《【]([^」》】]{2,60})[」》】]", text)
|
||||
if tm:
|
||||
slots["title"] = tm.group(1).strip()
|
||||
slots["theme"] = slots["title"]
|
||||
else:
|
||||
head = re.split(r"[。!!??\n]", text)[0].strip()
|
||||
slots["title"] = (head[:40] or "未命名 PBL 蓝图")
|
||||
slots["theme"] = head[:40]
|
||||
|
||||
missing = [f for f in CLARIFY_FIELDS if slots.get(f) in (None, "")]
|
||||
return slots, missing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observe/Propose:Designer 生成
|
||||
# ---------------------------------------------------------------------------
|
||||
def designer_generate(intent_text, owner_teacher_id=None, class_id=None,
|
||||
session_no=None, ctx=None, store=None, model_runner=None):
|
||||
"""
|
||||
US-01:NL 意图 → Blueprint 草稿。
|
||||
返回 {blueprint_id, code, version_no, generation_source, trace_no,
|
||||
clarifications[], slots, fallback_reason?}
|
||||
|
||||
模型不可达(PBL_E_MODEL_UNAVAILABLE / 后端未挂载)→ 自动兜底:
|
||||
generation_source=template_fallback(US-05),非错误。
|
||||
"""
|
||||
st = store or get_store()
|
||||
if ctx is None:
|
||||
fail(E_VALIDATION, "designer_generate 需要租户上下文 ctx")
|
||||
if not intent_text or not str(intent_text).strip():
|
||||
fail(E_VALIDATION, "intent_text 不能为空")
|
||||
|
||||
slots, missing = parse_intent(intent_text)
|
||||
trace_no = trace_mod.start_trace(
|
||||
DESIGNER, session_no=session_no,
|
||||
input_context={"api": "designer_generate", "intent_text": intent_text,
|
||||
"owner_teacher_id": owner_teacher_id, "class_id": class_id,
|
||||
"slots": slots, "missing_fields": missing},
|
||||
ctx=ctx, store=st)
|
||||
|
||||
# ① retrieved_knowledge:KDB 桩检索(空结果不报错)→ 降级本地模板库
|
||||
knowledge = []
|
||||
try:
|
||||
kdb = invoke_tool(ctx, DESIGNER, "kdb.search",
|
||||
{"query": intent_text[:200], "top_k": 5},
|
||||
trace_no=trace_no, session_no=session_no, store=st)
|
||||
knowledge.append({"source": "kdb.search", "stub": kdb.get("stub", True),
|
||||
"total": kdb.get("total", 0), "items": kdb.get("items") or []})
|
||||
except PblError as e:
|
||||
knowledge.append({"source": "kdb.search", "error_code": e.code,
|
||||
"note": "KDB 检索不可用,降级本地模板库"})
|
||||
tpl = {"items": [], "total": 0}
|
||||
try:
|
||||
tpl = invoke_tool(ctx, DESIGNER, "template.list",
|
||||
{"keyword": (slots.get("theme") or intent_text)[:40],
|
||||
"page": 1, "size": 5},
|
||||
trace_no=trace_no, session_no=session_no, store=st)
|
||||
knowledge.append({"source": "template.list", "total": tpl.get("total", 0),
|
||||
"items": tpl.get("items") or []})
|
||||
except PblError as e:
|
||||
knowledge.append({"source": "template.list", "error_code": e.code})
|
||||
trace_mod.append_trace(trace_no, "retrieved_knowledge", knowledge, ctx=ctx, store=st)
|
||||
|
||||
# ② Think:LLM 生成(可选)→ 失败即兜底
|
||||
generation_source = "ai_generated"
|
||||
fallback_reason = None
|
||||
llm_blueprint = None
|
||||
runner = model_runner or _default_model_runner()
|
||||
if runner is not None:
|
||||
try:
|
||||
llm_blueprint = runner(ctx, intent_text, slots)
|
||||
except Exception as e:
|
||||
llm_blueprint = None
|
||||
fallback_reason = "model_unreachable:%s" % e.__class__.__name__
|
||||
generation_source = "template_fallback"
|
||||
else:
|
||||
fallback_reason = "model_unbound"
|
||||
generation_source = "template_fallback"
|
||||
|
||||
# ③ Propose → Execute(经工具裁决)
|
||||
result = None
|
||||
if llm_blueprint:
|
||||
try:
|
||||
result = invoke_tool(ctx, DESIGNER, "blueprint.create", {
|
||||
"intent_text": intent_text,
|
||||
"title": llm_blueprint.get("title") or slots.get("title"),
|
||||
"class_id": class_id,
|
||||
"generation_source": "ai_generated",
|
||||
"owner_teacher_id": owner_teacher_id,
|
||||
"slots": llm_blueprint,
|
||||
}, trace_no=trace_no, session_no=session_no, store=st)
|
||||
except PblError as e:
|
||||
if e.code in (E_MODEL_UNAVAILABLE, E_BACKEND_UNAVAILABLE):
|
||||
fallback_reason = fallback_reason or e.code
|
||||
generation_source = "template_fallback"
|
||||
result = None
|
||||
else:
|
||||
raise
|
||||
|
||||
if result is None:
|
||||
# US-05 离线兜底:template.copy(无模板则本地最小蓝图)
|
||||
generation_source = "template_fallback"
|
||||
template_id = None
|
||||
items = (tpl or {}).get("items") or []
|
||||
if items:
|
||||
template_id = items[0].get("template_id") or items[0].get("id")
|
||||
if template_id:
|
||||
try:
|
||||
result = invoke_tool(ctx, DESIGNER, "template.copy", {
|
||||
"template_id": template_id,
|
||||
"owner_teacher_id": owner_teacher_id or ctx.actor_id,
|
||||
"class_id": class_id,
|
||||
"new_title": slots.get("title"),
|
||||
"generation_source": "template_fallback",
|
||||
}, trace_no=trace_no, session_no=session_no, store=st)
|
||||
except PblError as e:
|
||||
if e.code not in (E_NOT_FOUND, E_TEMPLATE_NONE,
|
||||
E_BACKEND_UNAVAILABLE):
|
||||
raise
|
||||
result = None
|
||||
if result is None:
|
||||
result = _offline_stub_blueprint(ctx, intent_text, slots, class_id,
|
||||
owner_teacher_id, trace_no, st)
|
||||
fallback_reason = (fallback_reason or "") + "|no_template:local_stub"
|
||||
|
||||
# ④ clarify(US-04:只问实质缺失,≤3 轮)
|
||||
clarifications = build_clarifications(missing, round_no=_clarify_round(st, ctx, session_no))
|
||||
trace_mod.append_trace(trace_no, "final_output", {
|
||||
"blueprint_id": result.get("blueprint_id"),
|
||||
"version_no": result.get("version_no"),
|
||||
"generation_source": generation_source,
|
||||
"clarifications": clarifications,
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
|
||||
ctx=ctx, store=st)
|
||||
|
||||
out = {
|
||||
"blueprint_id": result.get("blueprint_id"),
|
||||
"code": result.get("code"),
|
||||
"version_no": result.get("version_no") or 1,
|
||||
"generation_source": generation_source,
|
||||
"trace_no": trace_no,
|
||||
"session_no": session_no,
|
||||
"clarifications": clarifications,
|
||||
"slots": slots,
|
||||
"missing_fields": missing,
|
||||
}
|
||||
if fallback_reason:
|
||||
out["fallback_reason"] = fallback_reason
|
||||
return out
|
||||
|
||||
|
||||
def _offline_stub_blueprint(ctx, intent_text, slots, class_id, owner_teacher_id,
|
||||
trace_no, st):
|
||||
"""
|
||||
无 LLM 且无模板时的最小可用蓝图(离线兜底最后一环)。
|
||||
仍走 blueprint.create 工具(保持"Agent 只经工具写"铁律);
|
||||
若后端未挂载,落本地提案表 pbl_agent_proposal,返回提案号,待后端可用时补写。
|
||||
"""
|
||||
payload = {
|
||||
"intent_text": intent_text,
|
||||
"title": slots.get("title") or "未命名 PBL 蓝图",
|
||||
"class_id": class_id,
|
||||
"generation_source": "template_fallback",
|
||||
"owner_teacher_id": owner_teacher_id or ctx.actor_id,
|
||||
"slots": slots,
|
||||
}
|
||||
try:
|
||||
r = invoke_tool(ctx, DESIGNER, "blueprint.create", payload,
|
||||
trace_no=trace_no, store=st)
|
||||
return r
|
||||
except PblError as e:
|
||||
if e.code not in (E_BACKEND_UNAVAILABLE, E_MODEL_UNAVAILABLE):
|
||||
raise
|
||||
proposal_no = gen_no("PRP")
|
||||
st.C("pbl_agent_proposal", {
|
||||
"tenant_id": ctx.tenant_id, "proposal_no": proposal_no,
|
||||
"agent_code": DESIGNER, "trace_no": trace_no,
|
||||
"tool_code": "blueprint.create", "payload": dumps(payload),
|
||||
"status": "pending_backend", "created_at": now_ts(),
|
||||
})
|
||||
return {"blueprint_id": None, "code": proposal_no, "version_no": 1,
|
||||
"generation_source": "template_fallback", "trace_no": trace_no,
|
||||
"pending_backend": True}
|
||||
|
||||
|
||||
def _default_model_runner():
|
||||
"""
|
||||
默认模型绑定:平台无可用对话模型时返回 None(→ 走离线兜底)。
|
||||
不硬编码模型名;由应用装配时通过 set_model_runner 注入真实 runner。
|
||||
"""
|
||||
return _MODEL_RUNNER["runner"]
|
||||
|
||||
|
||||
_MODEL_RUNNER = {"runner": None}
|
||||
|
||||
|
||||
def set_model_runner(fn):
|
||||
"""
|
||||
注入 LLM runner:fn(ctx, intent_text, slots) -> dict(结构化蓝图槽位)。
|
||||
runner 抛异常即视为模型不可达,Designer 自动兜底(US-05)。
|
||||
"""
|
||||
_MODEL_RUNNER["runner"] = fn
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-04:澄清(≤3 轮,只问实质缺失)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _clarify_round(st, ctx, session_no):
|
||||
if not session_no:
|
||||
return 1
|
||||
rows = st.R("pbl_agent_trace",
|
||||
{"tenant_id": ctx.tenant_id, "session_no": session_no,
|
||||
"agent_code": DESIGNER}, order_by="id")
|
||||
n = 0
|
||||
for r in rows:
|
||||
ic = loads(r.get("input_context"), {}) or {}
|
||||
if isinstance(ic, dict) and ic.get("api") in ("designer_clarify",
|
||||
"designer_generate"):
|
||||
n += 1
|
||||
return max(1, n)
|
||||
|
||||
|
||||
_CLARIFY_QUESTIONS = {
|
||||
"age": "学生年龄段/年级是多少?(影响任务难度与阅读材料)",
|
||||
"teamSize": "每组团队规模几人?(影响角色分工设计)",
|
||||
"duration": "课程总时长多少(分钟/课时/周)?(影响阶段与里程碑数量)",
|
||||
"artifact": "最终产出物是什么(视频/海报/原型/报告/网页/演示/游戏)?",
|
||||
}
|
||||
|
||||
|
||||
def build_clarifications(missing_fields, round_no=1):
|
||||
"""
|
||||
生成澄清问题。轮次 > 3 时不再追问(US-04:≤3 轮),改为给出默认假设。
|
||||
"""
|
||||
missing_fields = [f for f in (missing_fields or []) if f in CLARIFY_FIELDS]
|
||||
if round_no > MAX_CLARIFY_ROUNDS:
|
||||
return [{"field": f, "question": None,
|
||||
"default_assumption": _DEFAULT_ASSUMPTIONS[f],
|
||||
"exceeded_max_rounds": True} for f in missing_fields]
|
||||
return [{"field": f, "question": _CLARIFY_QUESTIONS[f],
|
||||
"why": "实质影响设计(结构/难度/分工/评价)",
|
||||
"round": round_no} for f in missing_fields]
|
||||
|
||||
|
||||
_DEFAULT_ASSUMPTIONS = {
|
||||
"age": 12, "teamSize": 4, "duration": 45 * 8, "artifact": "presentation",
|
||||
}
|
||||
|
||||
|
||||
def designer_clarify(blueprint_id, missing_fields, ctx=None, store=None,
|
||||
session_no=None, round_no=None):
|
||||
"""
|
||||
US-04:对指定蓝图发起澄清。
|
||||
- 只接受实质影响设计的字段(age/teamSize/duration/artifact),其余忽略
|
||||
- 已提供(蓝图槽位已有值)的字段不重复问
|
||||
- 轮次 ≤ 3,超出返回默认假设而非继续追问
|
||||
返回 {questions[], round, max_rounds, exceeded, ignored_fields[]}
|
||||
"""
|
||||
st = store or get_store()
|
||||
if ctx is None:
|
||||
fail(E_VALIDATION, "designer_clarify 需要租户上下文 ctx")
|
||||
if not isinstance(missing_fields, (list, tuple)):
|
||||
fail(E_VALIDATION, "missing_fields 必须是列表")
|
||||
|
||||
ignored = [f for f in missing_fields if f not in CLARIFY_FIELDS]
|
||||
candidates = [f for f in missing_fields if f in CLARIFY_FIELDS]
|
||||
|
||||
# 已提供不重复问:读蓝图槽位
|
||||
provided = _read_provided_slots(ctx, blueprint_id, st)
|
||||
asked = [f for f in candidates if provided.get(f) in (None, "", [])]
|
||||
|
||||
rnd = int(round_no or _clarify_round(st, ctx, session_no))
|
||||
trace_no = trace_mod.start_trace(
|
||||
DESIGNER, session_no=session_no,
|
||||
input_context={"api": "designer_clarify", "blueprint_id": blueprint_id,
|
||||
"missing_fields": list(missing_fields), "round": rnd,
|
||||
"already_provided": sorted(provided.keys()),
|
||||
"ignored_fields": ignored},
|
||||
ctx=ctx, store=st)
|
||||
questions = build_clarifications(asked, round_no=rnd)
|
||||
trace_mod.append_trace(trace_no, "proposed_action", {
|
||||
"agent_code": DESIGNER, "action": "clarify", "questions": questions,
|
||||
"constraint": "≤3 轮,只问实质影响设计的缺失(US-04)",
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.append_trace(trace_no, "final_output", {"questions": questions},
|
||||
ctx=ctx, store=st)
|
||||
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
|
||||
ctx=ctx, store=st)
|
||||
return {"questions": questions, "round": rnd, "max_rounds": MAX_CLARIFY_ROUNDS,
|
||||
"exceeded": rnd > MAX_CLARIFY_ROUNDS, "ignored_fields": ignored,
|
||||
"already_provided": sorted(provided.keys()), "trace_no": trace_no}
|
||||
|
||||
|
||||
def _read_provided_slots(ctx, blueprint_id, st):
|
||||
"""读蓝图已有槽位(经 blueprint.get 工具,只读)。失败则返回空。"""
|
||||
if not blueprint_id:
|
||||
return {}
|
||||
try:
|
||||
r = invoke_tool(ctx, DESIGNER, "blueprint.get",
|
||||
{"blueprint_id": blueprint_id}, store=st)
|
||||
except PblError:
|
||||
return {}
|
||||
bp = (r or {}).get("blueprint") or {}
|
||||
slots = bp.get("slots") or bp.get("design_slots") or {}
|
||||
if isinstance(slots, str):
|
||||
slots = loads(slots, {}) or {}
|
||||
out = {}
|
||||
for f in CLARIFY_FIELDS:
|
||||
if f in slots:
|
||||
out[f] = slots[f]
|
||||
elif f in bp:
|
||||
out[f] = bp[f]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-03:对话式修改(结构化,非目标字段字节级不变)
|
||||
# ---------------------------------------------------------------------------
|
||||
_INSTRUCTION_RULES = [
|
||||
(re.compile(r"预算|经费|budget", re.I), "budget", "scene", "budget"),
|
||||
(re.compile(r"时长|时间|周期|duration", re.I), "duration", "scene", "duration"),
|
||||
(re.compile(r"人数|团队规模|分组|team", re.I), "teamSize", "team", "size"),
|
||||
(re.compile(r"年龄|年级|age", re.I), "age", "target", "age"),
|
||||
(re.compile(r"标题|题目|名字|title", re.I), "title", "blueprint", "title"),
|
||||
(re.compile(r"产出|成果|交付物|artifact", re.I), "artifact", "artifact", "type"),
|
||||
]
|
||||
|
||||
_NUM_PAT = re.compile(r"(\d+(?:\.\d+)?)\s*(万|亿|千|k|w)?")
|
||||
|
||||
|
||||
def instruction_to_changes(instruction, slots=None):
|
||||
"""
|
||||
把「把预算增加到500万」这类指令解析为 target_changes(结构化,US-03)。
|
||||
返回 (target_changes, unmatched_text)。禁止整篇重写:只产出字段级变更。
|
||||
"""
|
||||
text = (instruction or "").strip()
|
||||
if not text:
|
||||
fail(E_VALIDATION, "instruction 不能为空")
|
||||
changes = []
|
||||
for pat, slot, obj_type, field in _INSTRUCTION_RULES:
|
||||
if not pat.search(text):
|
||||
continue
|
||||
m = _NUM_PAT.search(text)
|
||||
if m and slot in ("budget", "duration", "teamSize", "age"):
|
||||
val = float(m.group(1))
|
||||
suf = (m.group(2) or "").lower()
|
||||
if suf in ("万", "w"):
|
||||
val *= 10000
|
||||
elif suf == "亿":
|
||||
val *= 100000000
|
||||
elif suf in ("千", "k"):
|
||||
val *= 1000
|
||||
if slot in ("teamSize", "age"):
|
||||
val = int(val)
|
||||
if slot == "duration":
|
||||
unit = None
|
||||
for k in _DURATION_UNIT_MIN:
|
||||
if k in text.lower():
|
||||
unit = k
|
||||
break
|
||||
if unit:
|
||||
val = int(val * _DURATION_UNIT_MIN[unit])
|
||||
else:
|
||||
val = int(val)
|
||||
changes.append({"object_type": obj_type, "field": field, "value": val,
|
||||
"slot": slot})
|
||||
else:
|
||||
qm = re.search(r"[「《【\"']([^」》】\"']{1,60})[」》】\"']", text)
|
||||
if qm:
|
||||
changes.append({"object_type": obj_type, "field": field,
|
||||
"value": qm.group(1).strip(), "slot": slot})
|
||||
elif slot == "artifact":
|
||||
for kw, code in _ARTIFACT_KEYS:
|
||||
if kw.lower() in text.lower():
|
||||
changes.append({"object_type": obj_type, "field": field,
|
||||
"value": code, "slot": slot})
|
||||
break
|
||||
return changes, text
|
||||
|
||||
|
||||
def designer_modify(blueprint_id, instruction, session_no=None, ctx=None,
|
||||
store=None, version_no=None):
|
||||
"""
|
||||
US-03:对话式修改 —— 改结构化模型(change_delta),生成新版本;
|
||||
非目标字段字节级不变(canonical_json diff 验证),禁止整篇重写散文。
|
||||
|
||||
返回 {new_version, change_delta, unaffected_verified, before_fingerprint,
|
||||
after_fingerprint, trace_no}
|
||||
错误:PBL_E_NOT_FOUND / PBL_E_STATE_ILLEGAL(archived)/ PBL_E_VALIDATION
|
||||
"""
|
||||
st = store or get_store()
|
||||
if ctx is None:
|
||||
fail(E_VALIDATION, "designer_modify 需要租户上下文 ctx")
|
||||
if not blueprint_id:
|
||||
fail(E_VALIDATION, "blueprint_id 必填")
|
||||
|
||||
trace_no = trace_mod.start_trace(
|
||||
DESIGNER, session_no=session_no,
|
||||
input_context={"api": "designer_modify", "blueprint_id": blueprint_id,
|
||||
"instruction": instruction, "version_no": version_no},
|
||||
ctx=ctx, store=st)
|
||||
|
||||
# Observe:读当前蓝图(经工具,只读)
|
||||
try:
|
||||
cur = invoke_tool(ctx, DESIGNER, "blueprint.get",
|
||||
{"blueprint_id": blueprint_id,
|
||||
**({"version_no": version_no} if version_no else {})},
|
||||
trace_no=trace_no, session_no=session_no, store=st)
|
||||
except PblError as e:
|
||||
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
|
||||
deny_code=e.code, deny_reason=e.message,
|
||||
ctx=ctx, store=st)
|
||||
raise
|
||||
bp = (cur or {}).get("blueprint") or {}
|
||||
status = (bp.get("status") or "").lower()
|
||||
if status == "archived":
|
||||
fail(E_STATE_ILLEGAL, "蓝图已归档,不可修改", blueprint_id=blueprint_id,
|
||||
status=status)
|
||||
|
||||
before = canonical_json({"blueprint": bp,
|
||||
"sub_objects": (cur or {}).get("sub_objects") or {}})
|
||||
|
||||
# Think:指令 → 结构化 target_changes
|
||||
changes, raw = instruction_to_changes(instruction)
|
||||
if not changes:
|
||||
trace_mod.append_trace(trace_no, "proposed_action", {
|
||||
"action": "modify", "instruction": instruction,
|
||||
"target_changes": [], "rejected": True,
|
||||
"reason": "指令无法解析为字段级结构化变更;禁止整篇重写(US-03)",
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
|
||||
deny_code=E_VALIDATION,
|
||||
deny_reason="指令未命中任何可结构化字段",
|
||||
ctx=ctx, store=st)
|
||||
fail(E_VALIDATION,
|
||||
"指令无法解析为结构化字段变更(US-03 禁止整篇重写):%s" % raw,
|
||||
instruction=instruction, trace_no=trace_no)
|
||||
|
||||
target_changes = [{"object_type": c["object_type"],
|
||||
"object_id": c.get("object_id"),
|
||||
"field": c["field"], "value": c["value"]} for c in changes]
|
||||
|
||||
# Propose → Execute(经 blueprint.update 工具,受裁决)
|
||||
res = invoke_tool(ctx, DESIGNER, "blueprint.update", {
|
||||
"blueprint_id": blueprint_id, "instruction": instruction,
|
||||
"target_changes": target_changes,
|
||||
}, trace_no=trace_no, session_no=session_no, store=st)
|
||||
|
||||
# 非目标字段字节级不变验证(canonical_json diff)
|
||||
try:
|
||||
after_cur = invoke_tool(ctx, DESIGNER, "blueprint.get",
|
||||
{"blueprint_id": blueprint_id,
|
||||
"version_no": res.get("new_version")},
|
||||
trace_no=trace_no, session_no=session_no, store=st)
|
||||
except PblError:
|
||||
after_cur = None
|
||||
unaffected = True
|
||||
after = None
|
||||
if after_cur:
|
||||
after_bp = dict((after_cur or {}).get("blueprint") or {})
|
||||
after = canonical_json({"blueprint": after_bp,
|
||||
"sub_objects": (after_cur or {}).get("sub_objects") or {}})
|
||||
untouched = _diff_untouched(bp, after_bp, changes)
|
||||
unaffected = untouched["ok"]
|
||||
trace_mod.append_trace(trace_no, "result", {
|
||||
"allow": True, "executed": True, "new_version": res.get("new_version"),
|
||||
"unaffected_verified": unaffected,
|
||||
"before_fingerprint": _fp(before), "after_fingerprint": _fp(after),
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.append_trace(trace_no, "final_output", {
|
||||
"new_version": res.get("new_version"),
|
||||
"change_delta": res.get("change_delta"),
|
||||
}, ctx=ctx, store=st)
|
||||
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
|
||||
ctx=ctx, store=st)
|
||||
|
||||
return {"blueprint_id": blueprint_id,
|
||||
"new_version": res.get("new_version"),
|
||||
"change_delta": res.get("change_delta") or
|
||||
{"instruction": instruction,
|
||||
"target_changes": target_changes},
|
||||
"unaffected_verified": unaffected,
|
||||
"before_fingerprint": _fp(before), "after_fingerprint": _fp(after),
|
||||
"trace_no": trace_no,
|
||||
"note": "仅目标字段变更;非目标字段字节级不变(US-03)"}
|
||||
|
||||
|
||||
def _fp(text):
|
||||
import hashlib
|
||||
if not text:
|
||||
return None
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _diff_untouched(before_bp, after_bp, changes):
|
||||
"""比对顶层非目标字段是否字节级不变。"""
|
||||
touched = set()
|
||||
for c in changes:
|
||||
touched.add(c.get("slot"))
|
||||
touched.add(c.get("field"))
|
||||
changed = []
|
||||
for k in set(list(before_bp.keys()) + list(after_bp.keys())):
|
||||
if k in ("updated_at", "version_no", "version", "id", "updated_by"):
|
||||
continue
|
||||
if canonical_json(before_bp.get(k)) != canonical_json(after_bp.get(k)):
|
||||
changed.append(k)
|
||||
unexpected = [k for k in changed if k not in touched]
|
||||
return {"ok": not unexpected, "changed": changed, "unexpected": unexpected}
|
||||
106
pbl_agent_runtime/m4a_init.py
Normal file
106
pbl_agent_runtime/m4a_init.py
Normal file
@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 模块挂载入口(load_pbl_agent_runtime_m4a)
|
||||
|
||||
应用 init() 中调用:
|
||||
from pbl_agent_runtime.m4a_init import load_pbl_agent_runtime_m4a
|
||||
load_pbl_agent_runtime_m4a(env) # env 为 ServerEnv(可选)
|
||||
|
||||
职责:
|
||||
1) 取模块库名(ServerEnv().get_module_dbname('pbl_agent_runtime'),禁止硬编码 DBNAME)
|
||||
2) 建表(幂等,4 主表 + 1 append-only 流水表)
|
||||
3) seed_agents:designer / critic(Critic write_allowed=0)
|
||||
4) seed_tools:13 enabled + 9 disabled(幂等 upsert)
|
||||
5) 注册 dspy API 路由(wwwroot/api/*.dspy 由平台按目录装载,此处仅登记契约清单)
|
||||
"""
|
||||
|
||||
from .m4a_kernel import get_store, set_store, MemoryStore, TenantContext, ACTOR_SYSTEM
|
||||
from .m4a_tables import ensure_tables, TABLES, APPEND_ONLY_TABLES, all_sql
|
||||
from .m4a_registry import (
|
||||
seed_agents, seed_tools, ENABLED_TOOL_CODES, DISABLED_TOOL_CODES,
|
||||
APPROVAL_REQUIRED_TOOLS, PERM_PLATFORM_ADMIN,
|
||||
)
|
||||
from .m4a import M4aApi, load_m4a
|
||||
|
||||
MODULE_NAME = "pbl_agent_runtime"
|
||||
|
||||
# 本模块对外 dspy API 契约清单(wwwroot/api/ 下文件,平台自动装载)
|
||||
API_ROUTES = [
|
||||
{"path": "api/pbl_agent_designer_run.dspy", "func": "designer_generate",
|
||||
"agent": "designer", "write": True, "note": "US-01 NL→Blueprint(含离线兜底)"},
|
||||
{"path": "api/pbl_agent_designer_modify.dspy", "func": "designer_modify",
|
||||
"agent": "designer", "write": True, "note": "US-03 结构化修改(change_delta)"},
|
||||
{"path": "api/pbl_agent_designer_clarify.dspy", "func": "designer_clarify",
|
||||
"agent": "designer", "write": False, "note": "US-04 澄清 ≤3 轮"},
|
||||
{"path": "api/pbl_agent_critic_run.dspy", "func": "critic_review",
|
||||
"agent": "critic", "write": False, "note": "US-18 只读评审(四要素)"},
|
||||
{"path": "api/pbl_tool_adjudicate.dspy", "func": "adjudicate_report",
|
||||
"agent": "*", "write": False, "note": "fail-closed 8 步裁决预检"},
|
||||
{"path": "api/pbl_tool_invoke.dspy", "func": "invoke_tool",
|
||||
"agent": "*", "write": True, "note": "工具调用唯一入口(裁决+执行+轨迹)"},
|
||||
{"path": "api/pbl_tool_registry_list.dspy", "func": "list_tools",
|
||||
"agent": "-", "write": False, "note": "13+9 注册表查询"},
|
||||
{"path": "api/pbl_tool_registry_save.dspy", "func": "register_tool/set_tool_status",
|
||||
"agent": "-", "write": True, "note": "仅 Platform Admin(人类)"},
|
||||
{"path": "api/pbl_agent_trace_list.dspy", "func": "list_traces",
|
||||
"agent": "-", "write": False, "note": "F-AG-04 轨迹查询"},
|
||||
{"path": "api/pbl_agent_trace_write.dspy", "func": "start_trace/append_trace",
|
||||
"agent": "*", "write": True, "note": "append-only(禁改禁删)"},
|
||||
{"path": "api/pbl_approval_create.dspy", "func": "request_approval",
|
||||
"agent": "designer", "write": True, "note": "T10 提案(Agent 不可自批)"},
|
||||
{"path": "api/pbl_approval_decide.dspy", "func": "decide_approval",
|
||||
"agent": "-", "write": True, "note": "仅 actor_type=user(14.2)"},
|
||||
{"path": "api/pbl_approval_list.dspy", "func": "list_pending_approvals",
|
||||
"agent": "-", "write": False, "note": "待办审批工作台"},
|
||||
]
|
||||
|
||||
|
||||
def get_dbname(env=None):
|
||||
"""取模块库名(禁止硬编码 DBNAME)。"""
|
||||
try:
|
||||
if env is not None and hasattr(env, "get_module_dbname"):
|
||||
return env.get_module_dbname(MODULE_NAME)
|
||||
from ahserver.serverenv import ServerEnv
|
||||
return ServerEnv().get_module_dbname(MODULE_NAME)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_pbl_agent_runtime_m4a(env=None, tenant_id=None, store=None,
|
||||
force_memory=False, do_seed=True):
|
||||
"""
|
||||
挂载 M4a:建表 + seed(幂等)。返回 {dbname, tables, seed, api}。
|
||||
tenant_id 为空时按平台级(__platform__)seed,租户首次调用时再按租户 seed。
|
||||
"""
|
||||
dbname = get_dbname(env)
|
||||
st = store or get_store(force_memory=force_memory)
|
||||
tables = ensure_tables(st)
|
||||
seed = None
|
||||
if do_seed:
|
||||
ctx = TenantContext(tenant_id=tenant_id or "__platform__",
|
||||
actor_type=ACTOR_SYSTEM, actor_id="seed",
|
||||
permissions={PERM_PLATFORM_ADMIN})
|
||||
seed = {"agents": seed_agents(ctx=ctx, store=st),
|
||||
"tools": seed_tools(ctx=ctx, store=st)}
|
||||
return {
|
||||
"module": MODULE_NAME,
|
||||
"part": "M4a",
|
||||
"dbname": dbname,
|
||||
"tables": tables,
|
||||
"append_only_tables": list(APPEND_ONLY_TABLES),
|
||||
"seed": seed,
|
||||
"enabled_tools": list(ENABLED_TOOL_CODES),
|
||||
"disabled_tools": list(DISABLED_TOOL_CODES),
|
||||
"approval_required_tools": list(APPROVAL_REQUIRED_TOOLS),
|
||||
"api_routes": API_ROUTES,
|
||||
}
|
||||
|
||||
|
||||
def bootstrap(tenant_id, store=None, force_memory=False):
|
||||
"""租户级快捷装配(等价 load_m4a),返回 M4aApi。"""
|
||||
return load_m4a(tenant_id=tenant_id, store=store, force_memory=force_memory)
|
||||
|
||||
|
||||
def ddl():
|
||||
"""导出全部 DDL(供 sql/m4a_ddl.sql 生成与部署核对)。"""
|
||||
return all_sql()
|
||||
389
pbl_agent_runtime/m4a_kernel.py
Normal file
389
pbl_agent_runtime/m4a_kernel.py
Normal file
@ -0,0 +1,389 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 公共内核(错误码 / 租户上下文 / 存储适配 / 工具函数)
|
||||
|
||||
设计锚点:
|
||||
- modules/pbl_agent_runtime.md §4 fail-closed 裁决(default-deny)
|
||||
- agent-tool-contract.md §4 裁决与审计规约
|
||||
- pbl_common 约定:所有读写 tenant_id 强制打头,缺失即 PBL_E_TENANT_MISSING
|
||||
|
||||
本文件不依赖 pbl_common 硬导入:优先复用 pbl_common 的错误码/审计/存储,
|
||||
缺失时降级到本文件内置实现,保证模块可独立单测(离线可跑)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 错误码(与 pbl_common 对齐;M4a 用到的全集)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
E_TENANT_MISSING = "PBL_E_TENANT_MISSING" # 租户上下文缺失/无效
|
||||
E_FORBIDDEN = "PBL_E_FORBIDDEN" # 未注册 / 禁用 / 越权 / 读他人轨迹
|
||||
E_STATE_ILLEGAL = "PBL_E_STATE_ILLEGAL" # 需审批未批 / 状态不允许
|
||||
E_NOT_FOUND = "PBL_E_NOT_FOUND" # 对象不存在(含跨租户 404)
|
||||
E_VALIDATION = "PBL_E_VALIDATION" # 入参契约 / 四要素不齐
|
||||
E_DUPLICATE = "PBL_E_DUPLICATE" # 重复注册
|
||||
E_APPEND_ONLY = "PBL_E_APPEND_ONLY" # append-only 轨迹不可改
|
||||
E_MODEL_UNAVAILABLE = "PBL_E_MODEL_UNAVAILABLE" # 模型不可达(Designer 内部兜底,不外抛)
|
||||
E_BACKEND_UNAVAILABLE = "PBL_E_BACKEND_UNAVAILABLE" # 后端权威服务未挂载
|
||||
E_TEMPLATE_NONE = "PBL_E_TEMPLATE_NONE" # 无可用模板
|
||||
|
||||
# 裁决链 8 步(M4a 核心常量,顺序即执行顺序,任一步失败立即拒绝)
|
||||
ADJUDICATION_STEPS = (
|
||||
("S1", "tenant_context", "租户上下文有效(tenant_id 强制打头)", E_TENANT_MISSING),
|
||||
("S2", "agent_registered", "Agent 已注册且 enabled(仅 designer/critic 两个)", E_FORBIDDEN),
|
||||
("S3", "tool_registered", "工具已注册(白名单外 default-deny)", E_FORBIDDEN),
|
||||
("S4", "tool_enabled", "工具 status=enabled(禁用工具返回 disable_reason)", E_FORBIDDEN),
|
||||
("S5", "permission_domain", "调用方持有 required_permission 权限域", E_FORBIDDEN),
|
||||
("S6", "agent_scope", "allowed_agents 含调用方;Critic 零写权限", E_FORBIDDEN),
|
||||
("S7", "approval_gate", "require_approval=1 须有 approved 人工审批记录", E_STATE_ILLEGAL),
|
||||
("S8", "input_contract", "入参满足 input_schema(必填/类型/枚举)", E_VALIDATION),
|
||||
)
|
||||
|
||||
# 轨迹 7 要素(第 28 章,US-20)
|
||||
TRACE_ELEMENTS = (
|
||||
"input_context",
|
||||
"retrieved_knowledge",
|
||||
"tool_calls",
|
||||
"proposed_action",
|
||||
"result",
|
||||
"approval",
|
||||
"final_output",
|
||||
)
|
||||
|
||||
TRACE_STATUS_OPEN = "open"
|
||||
TRACE_STATUS_DONE = "done"
|
||||
TRACE_STATUS_DENY = "deny"
|
||||
|
||||
TOOL_STATUS_ENABLED = "enabled"
|
||||
TOOL_STATUS_DISABLED = "disabled"
|
||||
|
||||
AGENT_STATUS_ENABLED = "enabled"
|
||||
AGENT_STATUS_DISABLED = "disabled"
|
||||
|
||||
ACTOR_USER = "user"
|
||||
ACTOR_AGENT = "agent"
|
||||
ACTOR_SYSTEM = "system"
|
||||
|
||||
|
||||
class PblError(Exception):
|
||||
"""PBL 业务异常:code 为 PBL_E_* 错误码,payload 携带可审计上下文。"""
|
||||
|
||||
def __init__(self, code, message="", **payload):
|
||||
super(PblError, self).__init__("[%s] %s" % (code, message))
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.payload = payload
|
||||
|
||||
def to_dict(self):
|
||||
d = {"ok": False, "error_code": self.code, "error_message": self.message}
|
||||
d.update(self.payload)
|
||||
return d
|
||||
|
||||
|
||||
def fail(code, message="", **payload):
|
||||
"""抛出 PblError(统一入口,便于后续接 pbl_common.errors)。"""
|
||||
raise PblError(code, message, **payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 租户上下文
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TenantContext(object):
|
||||
"""一次调用的租户 + 调用方上下文。tenant_id 缺失即 fail-closed。"""
|
||||
|
||||
__slots__ = ("tenant_id", "actor_type", "actor_id", "permissions", "org_id")
|
||||
|
||||
def __init__(self, tenant_id=None, actor_type=ACTOR_AGENT, actor_id=None,
|
||||
permissions=None, org_id=None):
|
||||
self.tenant_id = tenant_id
|
||||
self.actor_type = actor_type
|
||||
self.actor_id = actor_id
|
||||
self.permissions = set(permissions or ())
|
||||
self.org_id = org_id
|
||||
|
||||
def is_human(self):
|
||||
return self.actor_type == ACTOR_USER
|
||||
|
||||
def has_perm(self, perm):
|
||||
if not perm:
|
||||
return True
|
||||
if perm in self.permissions:
|
||||
return True
|
||||
# 通配:pbl_authoring.* 覆盖 pbl_authoring.blueprint
|
||||
if perm + ".*" in self.permissions:
|
||||
return True
|
||||
return "*" in self.permissions
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"actor_type": self.actor_type,
|
||||
"actor_id": self.actor_id,
|
||||
"permissions": sorted(self.permissions),
|
||||
"org_id": self.org_id,
|
||||
}
|
||||
|
||||
|
||||
def require_tenant(ctx):
|
||||
"""S1:租户上下文校验。tenant_id 必须非空字符串。"""
|
||||
if ctx is None:
|
||||
fail(E_TENANT_MISSING, "缺少租户上下文(ctx=None)")
|
||||
tid = getattr(ctx, "tenant_id", None)
|
||||
if not isinstance(tid, str) or not tid.strip():
|
||||
fail(E_TENANT_MISSING, "tenant_id 缺失或非法", got=repr(tid))
|
||||
return tid.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 存储适配(sqlor 优先,缺失降级内存表;接口对齐 sor.C/R/U/D/I)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MemoryStore(object):
|
||||
"""内存表存储,仅用于离线单测与后端未挂载时的降级;接口对齐 sqlor。"""
|
||||
|
||||
def __init__(self):
|
||||
self._tables = {}
|
||||
self._seq = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _tbl(self, tblname):
|
||||
with self._lock:
|
||||
return self._tables.setdefault(tblname, [])
|
||||
|
||||
@staticmethod
|
||||
def _match(row, where):
|
||||
if not where:
|
||||
return True
|
||||
for k, v in where.items():
|
||||
if isinstance(v, (list, tuple, set)):
|
||||
if row.get(k) not in v:
|
||||
return False
|
||||
elif row.get(k) != v:
|
||||
return False
|
||||
return True
|
||||
|
||||
def C(self, tblname, data):
|
||||
with self._lock:
|
||||
rows = self._tbl(tblname)
|
||||
self._seq[tblname] = self._seq.get(tblname, 0) + 1
|
||||
row = dict(data)
|
||||
row["id"] = self._seq[tblname]
|
||||
rows.append(row)
|
||||
return row["id"]
|
||||
|
||||
def R(self, tblname, where=None, order_by=None, limit=None, offset=None):
|
||||
with self._lock:
|
||||
rows = [dict(r) for r in self._tbl(tblname) if self._match(r, where)]
|
||||
if order_by:
|
||||
desc = order_by.startswith("-")
|
||||
key = order_by[1:] if desc else order_by
|
||||
rows.sort(key=lambda r: (r.get(key) is None, r.get(key)), reverse=desc)
|
||||
if offset:
|
||||
rows = rows[int(offset):]
|
||||
if limit:
|
||||
rows = rows[:int(limit)]
|
||||
return rows
|
||||
|
||||
def U(self, tblname, data, where):
|
||||
with self._lock:
|
||||
n = 0
|
||||
for row in self._tbl(tblname):
|
||||
if self._match(row, where):
|
||||
row.update(data)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def D(self, tblname, where):
|
||||
with self._lock:
|
||||
rows = self._tbl(tblname)
|
||||
keep = [r for r in rows if not self._match(r, where)]
|
||||
n = len(rows) - len(keep)
|
||||
self._tables[tblname] = keep
|
||||
return n
|
||||
|
||||
def I(self, sql, args=None):
|
||||
raise NotImplementedError("MemoryStore 不支持裸 SQL: %s" % (sql,))
|
||||
|
||||
def sqlExe(self, sql, args=None):
|
||||
raise NotImplementedError("MemoryStore 不支持裸 SQL: %s" % (sql,))
|
||||
|
||||
|
||||
class AppendOnlyGuard(object):
|
||||
"""
|
||||
append-only 存储守卫:包裹底层 store,对受保护表禁止 U/D。
|
||||
pbl_agent_trace 为 append-only(US-20 不可篡改);
|
||||
轨迹状态推进只允许通过受控的 _advance_trace_status 白名单通道。
|
||||
"""
|
||||
|
||||
PROTECTED = ("pbl_agent_trace", "pbl_agent_trace_stage")
|
||||
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
self._bypass = threading.local()
|
||||
|
||||
# --- 受控通道:仅内核可临时解除保护(状态推进/要素追加) ---
|
||||
class _Bypass(object):
|
||||
def __init__(self, guard):
|
||||
self.g = guard
|
||||
|
||||
def __enter__(self):
|
||||
self.g._bypass.on = True
|
||||
return self.g._store
|
||||
|
||||
def __exit__(self, *a):
|
||||
self.g._bypass.on = False
|
||||
return False
|
||||
|
||||
def mutable(self):
|
||||
return AppendOnlyGuard._Bypass(self)
|
||||
|
||||
def _check(self, tblname):
|
||||
if tblname in AppendOnlyGuard.PROTECTED and not getattr(self._bypass, "on", False):
|
||||
fail(E_APPEND_ONLY, "表 %s 为 append-only,禁止 update/delete" % tblname,
|
||||
table=tblname)
|
||||
|
||||
def C(self, tblname, data):
|
||||
return self._store.C(tblname, data)
|
||||
|
||||
def R(self, tblname, where=None, order_by=None, limit=None, offset=None):
|
||||
return self._store.R(tblname, where, order_by=order_by, limit=limit, offset=offset)
|
||||
|
||||
def U(self, tblname, data, where):
|
||||
self._check(tblname)
|
||||
return self._store.U(tblname, data, where)
|
||||
|
||||
def D(self, tblname, where):
|
||||
self._check(tblname)
|
||||
return self._store.D(tblname, where)
|
||||
|
||||
def I(self, sql, args=None):
|
||||
return self._store.I(sql, args)
|
||||
|
||||
def sqlExe(self, sql, args=None):
|
||||
return self._store.sqlExe(sql, args)
|
||||
|
||||
|
||||
_STORE = None
|
||||
_STORE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def get_store(force_memory=False):
|
||||
"""
|
||||
取存储句柄。优先 sqlor(ServerEnv 注入的模块库),失败降级 MemoryStore。
|
||||
返回的对象带 append-only 守卫。
|
||||
"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
if _STORE is not None and not force_memory:
|
||||
return _STORE
|
||||
raw = None
|
||||
if not force_memory:
|
||||
try:
|
||||
from sqlor import sqlor as _sor # noqa
|
||||
raw = _sor
|
||||
except Exception:
|
||||
raw = None
|
||||
if raw is None:
|
||||
raw = MemoryStore()
|
||||
_STORE = AppendOnlyGuard(raw)
|
||||
return _STORE
|
||||
|
||||
|
||||
def set_store(store):
|
||||
"""测试/装配注入点:显式设置存储(自动包 append-only 守卫)。"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
_STORE = store if isinstance(store, AppendOnlyGuard) else AppendOnlyGuard(store)
|
||||
return _STORE
|
||||
|
||||
|
||||
def reset_store():
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
_STORE = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def now_ts():
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
|
||||
|
||||
def gen_no(prefix):
|
||||
"""生成业务编号:前缀 + 时间戳 + 短 uuid,全局唯一、可排序。"""
|
||||
return "%s%s%s" % (
|
||||
prefix,
|
||||
time.strftime("%Y%m%d%H%M%S", time.localtime()),
|
||||
uuid.uuid4().hex[:8].upper(),
|
||||
)
|
||||
|
||||
|
||||
def canonical_json(obj):
|
||||
"""规范化 JSON(键排序、无多余空白)——用于 change_delta 字节级比对(US-03)。"""
|
||||
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
|
||||
default=str)
|
||||
|
||||
|
||||
def dumps(obj):
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
return json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def loads(text, default=None):
|
||||
if text is None or text == "":
|
||||
return default
|
||||
if isinstance(text, (dict, list)):
|
||||
return text
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def one(store, tblname, where):
|
||||
"""取单行;无则 None。where 必须已含 tenant_id。"""
|
||||
rows = store.R(tblname, where, limit=1)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def first_or_404(store, tblname, where, **payload):
|
||||
row = one(store, tblname, where)
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "%s 不存在(或跨租户不可见)" % tblname,
|
||||
table=tblname, **payload)
|
||||
return row
|
||||
|
||||
|
||||
def write_audit(ctx, action, object_type, object_id, detail=None, store=None):
|
||||
"""
|
||||
审计联动(agent-tool-contract.md §4.5):工具执行/治理变更成功后写审计。
|
||||
优先复用 pbl_common.audit;不可用时落本地 pbl_agent_audit 表。
|
||||
审计写失败必须让业务回滚(此处向上抛错,由调用方事务处理)。
|
||||
"""
|
||||
record = {
|
||||
"tenant_id": getattr(ctx, "tenant_id", None),
|
||||
"actor_type": getattr(ctx, "actor_type", None),
|
||||
"actor_id": getattr(ctx, "actor_id", None),
|
||||
"action": action,
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
"detail": dumps(detail),
|
||||
"created_at": now_ts(),
|
||||
}
|
||||
try:
|
||||
from pbl_common.audit import write_audit as _common_audit # noqa
|
||||
return _common_audit(record)
|
||||
except Exception:
|
||||
pass
|
||||
st = store or get_store()
|
||||
return st.C("pbl_agent_audit", record)
|
||||
638
pbl_agent_runtime/m4a_registry.py
Normal file
638
pbl_agent_runtime/m4a_registry.py
Normal file
@ -0,0 +1,638 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — Agent 定义与工具注册表(含 seed 幂等注入)
|
||||
|
||||
内容:
|
||||
1) AGENT_DEFS:第 13.1 章仅 designer / critic 两个 Agent;critic write_allowed=0(零写权限)
|
||||
2) TOOL_SEED:第 31 章裁剪子集 —— 13 enabled + 9 disabled(agent-tool-contract.md §2/§3)
|
||||
3) register_tool / list_tools / set_tool_status / seed_tools / seed_agents(幂等,build.sh 调用)
|
||||
|
||||
四类强制人工审批(14.2):
|
||||
publish(T12 publish.request)/ compile_execute(T11 compile.trigger)/
|
||||
blueprint_approve(蓝图 approved 状态推进)/ tool_registry_change(注册表启停变更)
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, write_audit,
|
||||
E_FORBIDDEN, E_DUPLICATE, E_NOT_FOUND,
|
||||
TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED,
|
||||
AGENT_STATUS_ENABLED, ACTOR_USER,
|
||||
)
|
||||
|
||||
PERM_AUTHORING = "pbl_authoring"
|
||||
PERM_AGENT_TOOLS = "agent_tools"
|
||||
PERM_PUBLISHING = "publishing"
|
||||
PERM_KDB = "kdb"
|
||||
PERM_PLATFORM_ADMIN = "platform_admin"
|
||||
|
||||
# 四类强制人工审批
|
||||
APPROVAL_TYPES = ("publish", "compile_execute", "blueprint_approve",
|
||||
"tool_registry_change")
|
||||
|
||||
DESIGNER = "designer"
|
||||
CRITIC = "critic"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Agent 定义(第 13.1 章:仅此两个)
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENT_DEFS = [
|
||||
{
|
||||
"agent_code": DESIGNER,
|
||||
"agent_name": "Designer Agent",
|
||||
"agent_type": "authoring",
|
||||
"loop_pattern": "observe_think_propose_execute",
|
||||
"authority_boundary":
|
||||
"Agent 非真相源(14.1):Designer 只 Propose,全部写操作经工具裁决链落到"
|
||||
"权威系统(pbl_blueprint / pbl_validation / pbl_compiler);"
|
||||
"Execute 受裁决,Publish 必须人工审批(14.2)。",
|
||||
"write_allowed": 1,
|
||||
"allowed_tools": [
|
||||
"blueprint.create", "blueprint.update", "blueprint.get",
|
||||
"template.list", "template.copy",
|
||||
"validation.run", "validation.report",
|
||||
"trace.get", "approval.request",
|
||||
"compile.trigger", "publish.request", "kdb.search",
|
||||
],
|
||||
# critic.review 属 critic 域,designer 不调用
|
||||
"denied_tools": [
|
||||
"critic.review",
|
||||
"blueprint.publish_auto", "curriculum.modify_auto",
|
||||
"marketplace.create_listing", "kdb.write", "research.collect",
|
||||
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
|
||||
"billing.charge",
|
||||
],
|
||||
"model_binding": {
|
||||
"model": "", "capability": "t2t",
|
||||
"fallback": "template_fallback",
|
||||
"note": "模型不可达 → PBL_E_MODEL_UNAVAILABLE 内部捕获,自动切 template.copy 兜底(US-05)",
|
||||
},
|
||||
"offline_fallback": "template_fallback",
|
||||
"status": AGENT_STATUS_ENABLED,
|
||||
"description": "NL 意图 → Blueprint 草稿(生成 / 对话式修改提案)",
|
||||
},
|
||||
{
|
||||
"agent_code": CRITIC,
|
||||
"agent_name": "Critic Agent",
|
||||
"agent_type": "review",
|
||||
"loop_pattern": "observe_think_propose",
|
||||
"authority_boundary":
|
||||
"Critic 零写权限(14.1):不直接修改 Blueprint,仅产出建议"
|
||||
"(recommendation / reason / evidence / confidence,14.3);"
|
||||
"只读 blueprint.get + validation.report,无任何 write_operation=1 工具。",
|
||||
"write_allowed": 0,
|
||||
"allowed_tools": ["blueprint.get", "validation.report", "critic.review",
|
||||
"trace.get"],
|
||||
# 零写权限:所有写工具显式拉黑(S6 双重防线)
|
||||
"denied_tools": [
|
||||
"blueprint.create", "blueprint.update", "template.copy",
|
||||
"validation.run", "compile.trigger", "publish.request",
|
||||
"approval.request",
|
||||
"blueprint.publish_auto", "curriculum.modify_auto",
|
||||
"marketplace.create_listing", "kdb.write", "research.collect",
|
||||
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
|
||||
"billing.charge",
|
||||
],
|
||||
"model_binding": {
|
||||
"model": "", "capability": "t2t", "fallback": "rule_based",
|
||||
"note": "模型不可达 → 降级为规则式建议(读 validation.report 告警),仍产出四要素",
|
||||
},
|
||||
"offline_fallback": "none",
|
||||
"status": AGENT_STATUS_ENABLED,
|
||||
"description": "质量告警与改进建议(只读评审,不改蓝图)",
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 工具注册表 seed(13 enabled + 9 disabled)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _t(code, name, group, status, perm, approval, agents, write, ischema,
|
||||
oschema, backend, sort, disable_reason=None, approval_type=None,
|
||||
scope_note=None):
|
||||
return {
|
||||
"tool_code": code, "tool_name": name, "tool_group": group, "status": status,
|
||||
"required_permission": perm, "require_approval": approval,
|
||||
"approval_action_type": approval_type, "allowed_agents": agents,
|
||||
"write_operation": write, "input_schema": ischema, "output_schema": oschema,
|
||||
"backend_mapping": backend, "disable_reason": disable_reason,
|
||||
"scope_note": scope_note, "sort_no": sort,
|
||||
}
|
||||
|
||||
|
||||
TOOL_SEED = [
|
||||
# ---------------- 启用(13) ----------------
|
||||
_t("blueprint.create", "创建蓝图", "blueprint", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER], 1,
|
||||
{"type": "object",
|
||||
"required": ["intent_text", "title"],
|
||||
"properties": {
|
||||
"intent_text": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"class_id": {"type": "string"},
|
||||
"generation_source": {"type": "string",
|
||||
"enum": ["ai_generated", "manual",
|
||||
"template_fallback", "template_copy"]}}},
|
||||
{"type": "object",
|
||||
"properties": {"blueprint_id": {"type": "integer"}, "code": {"type": "string"},
|
||||
"version_no": {"type": "integer"},
|
||||
"generation_source": {"type": "string"},
|
||||
"trace_no": {"type": "string"}}},
|
||||
"pbl_blueprint.create_blueprint + create_version", 1,
|
||||
scope_note="T1;PBL_E_MODEL_UNAVAILABLE 内部切 template.copy 兜底,不向 Agent 抛错"),
|
||||
|
||||
_t("blueprint.update", "结构化修改蓝图", "blueprint", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER], 1,
|
||||
{"type": "object",
|
||||
"required": ["blueprint_id", "instruction", "target_changes"],
|
||||
"properties": {
|
||||
"blueprint_id": {"type": "integer"},
|
||||
"instruction": {"type": "string"},
|
||||
"target_changes": {"type": "array", "minItems": 1, "items": {
|
||||
"type": "object", "required": ["object_type", "field", "value"],
|
||||
"properties": {"object_type": {"type": "string"},
|
||||
"object_id": {"type": "integer"},
|
||||
"field": {"type": "string"},
|
||||
"value": {}}}}}},
|
||||
{"type": "object",
|
||||
"properties": {"new_version": {"type": "integer"},
|
||||
"change_delta": {"type": "object"},
|
||||
"unaffected_verified": {"type": "boolean"}}},
|
||||
"pbl_blueprint.update_sub_object + create_version(change_delta)", 2,
|
||||
scope_note="T2;仅目标字段变更,非目标字段字节级不变(US-03 canonical_json diff)"),
|
||||
|
||||
_t("blueprint.get", "读取蓝图", "blueprint", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER, CRITIC], 0,
|
||||
{"type": "object", "required": ["blueprint_id"],
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"version_no": {"type": "integer"},
|
||||
"include": {"type": "array", "items": {"type": "string"}}}},
|
||||
{"type": "object",
|
||||
"properties": {"blueprint": {"type": "object"}, "version": {"type": "object"},
|
||||
"sub_objects": {"type": "object"}}},
|
||||
"pbl_blueprint.get_blueprint / get_version / get_blueprint_tree", 3,
|
||||
scope_note="T3;租户隔离,跨租户返回 PBL_E_NOT_FOUND"),
|
||||
|
||||
_t("template.list", "模板检索", "template", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER], 0,
|
||||
{"type": "object",
|
||||
"properties": {"category": {"type": "string"}, "keyword": {"type": "string"},
|
||||
"page": {"type": "integer"}, "size": {"type": "integer"}}},
|
||||
{"type": "object",
|
||||
"properties": {"items": {"type": "array"}, "total": {"type": "integer"}}},
|
||||
"pbl_template.list_templates + match_by_intent", 4,
|
||||
scope_note="T4;kdb.search 桩返回空时 Designer 降级到本工具(本地模板库)"),
|
||||
|
||||
_t("template.copy", "模板实例化", "template", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER], 1,
|
||||
{"type": "object", "required": ["template_id", "owner_teacher_id"],
|
||||
"properties": {"template_id": {"type": "integer"},
|
||||
"owner_teacher_id": {"type": "string"},
|
||||
"class_id": {"type": "string"},
|
||||
"new_title": {"type": "string"}}},
|
||||
{"type": "object",
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"version_no": {"type": "integer"},
|
||||
"generation_source": {"type": "string"},
|
||||
"usage_id": {"type": "integer"}}},
|
||||
"pbl_template.copy_template_to_blueprint / fallback_instantiate", 5,
|
||||
scope_note="T5;模型不可达兜底落点(generation_source=template_fallback)"),
|
||||
|
||||
_t("validation.run", "触发校验", "validation", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER], 0,
|
||||
{"type": "object", "required": ["blueprint_id", "version_no"],
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"version_no": {"type": "integer"}}},
|
||||
{"type": "object",
|
||||
"properties": {"run_no": {"type": "string"},
|
||||
"quality_status": {"type": "string"},
|
||||
"pass_count": {"type": "integer"},
|
||||
"warn_count": {"type": "integer"},
|
||||
"fail_count": {"type": "integer"}}},
|
||||
"pbl_validation.run_validation", 6,
|
||||
scope_note="T6;确定性校验,无 LLM"),
|
||||
|
||||
_t("validation.report", "校验报告读取", "validation", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [DESIGNER, CRITIC], 0,
|
||||
{"type": "object",
|
||||
"properties": {"run_id": {"type": "integer"},
|
||||
"blueprint_id": {"type": "integer"}}},
|
||||
{"type": "object",
|
||||
"properties": {"run": {"type": "object"}, "dimensions": {"type": "array"},
|
||||
"alerts": {"type": "array"}}},
|
||||
"pbl_validation.get_validation_report", 7,
|
||||
scope_note="T7;Critic 建议证据源(14 维 + 4 内置告警)"),
|
||||
|
||||
_t("critic.review", "Critic 评审", "critic", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 0, [CRITIC], 0,
|
||||
{"type": "object", "required": ["blueprint_id"],
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"version_no": {"type": "integer"}}},
|
||||
{"type": "object",
|
||||
"properties": {"suggestions": {"type": "array"},
|
||||
"trace_no": {"type": "string"}}},
|
||||
"pbl_agent_runtime.critic_review(读 T3/T7,零写蓝图)", 8,
|
||||
scope_note="T8;write_operation=0,Critic 不直接修改 Blueprint(14.1);"
|
||||
"四要素不齐 → PBL_E_VALIDATION(14.3)"),
|
||||
|
||||
_t("trace.get", "轨迹读取", "trace", TOOL_STATUS_ENABLED,
|
||||
PERM_AGENT_TOOLS, 0, [DESIGNER, CRITIC], 0,
|
||||
{"type": "object", "required": ["trace_no"],
|
||||
"properties": {"trace_no": {"type": "string"}}},
|
||||
{"type": "object",
|
||||
"properties": {e: {} for e in ("input_context", "retrieved_knowledge",
|
||||
"tool_calls", "proposed_action", "result",
|
||||
"approval", "final_output")}},
|
||||
"pbl_agent_runtime.get_trace(append-only)", 9,
|
||||
scope_note="T9;Agent 仅可读自身 trace_no,读他人 → PBL_E_FORBIDDEN"),
|
||||
|
||||
_t("approval.request", "发起人工审批", "approval", TOOL_STATUS_ENABLED,
|
||||
PERM_AGENT_TOOLS, 0, [DESIGNER], 0,
|
||||
{"type": "object", "required": ["trace_id", "action_type"],
|
||||
"properties": {"trace_id": {"type": "integer"},
|
||||
"action_type": {"type": "string",
|
||||
"enum": list(APPROVAL_TYPES)},
|
||||
"approver_id": {"type": "string"}}},
|
||||
{"type": "object",
|
||||
"properties": {"approval_no": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["pending"]}}},
|
||||
"pbl_agent_runtime.request_approval", 10,
|
||||
scope_note="T10;Agent 不能自批——decide_approval 仅 actor_type=user 可调(14.2)"),
|
||||
|
||||
_t("compile.trigger", "触发编译(需审批)", "compile", TOOL_STATUS_ENABLED,
|
||||
PERM_AUTHORING, 1, [DESIGNER], 1,
|
||||
{"type": "object",
|
||||
"required": ["blueprint_id", "version_no", "compiler_version"],
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"version_no": {"type": "integer"},
|
||||
"compiler_version": {"type": "string"}}},
|
||||
{"type": "object",
|
||||
"properties": {"task_no": {"type": "string"}, "status": {"type": "string"},
|
||||
"game_def_id": {"type": "integer"},
|
||||
"fingerprint": {"type": "string"}}},
|
||||
"pbl_compiler.compile(is_approved 门禁:未审批蓝图 403)", 11,
|
||||
approval_type="compile_execute",
|
||||
scope_note="T11;require_approval=1,无 approved 记录 → PBL_E_STATE_ILLEGAL"),
|
||||
|
||||
_t("publish.request", "发布请求(需审批)", "approval", TOOL_STATUS_ENABLED,
|
||||
PERM_PUBLISHING, 1, [DESIGNER], 1,
|
||||
{"type": "object", "required": ["blueprint_id", "visibility"],
|
||||
"properties": {"blueprint_id": {"type": "integer"},
|
||||
"visibility": {"type": "string",
|
||||
"enum": ["private", "org", "school", "class"]}}},
|
||||
{"type": "object",
|
||||
"properties": {"approval_no": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"blueprint_status": {"type": "string"},
|
||||
"visibility": {"type": "string"}}},
|
||||
"pbl_blueprint.submit_approval(publish_approve) + status 更新", 12,
|
||||
approval_type="publish",
|
||||
scope_note="T12;Publish 必须人工审批(14.2/36 章);仅可见性标记,无 Marketplace(1.3)"),
|
||||
|
||||
_t("kdb.search", "知识检索(桩)", "kdb", TOOL_STATUS_ENABLED,
|
||||
PERM_KDB, 0, [DESIGNER], 0,
|
||||
{"type": "object", "required": ["query"],
|
||||
"properties": {"query": {"type": "string"}, "top_k": {"type": "integer"}}},
|
||||
{"type": "object",
|
||||
"properties": {"items": {"type": "array"}, "total": {"type": "integer"},
|
||||
"stub": {"type": "boolean"}}},
|
||||
"pbl_kdb_ext.kdb_search", 13,
|
||||
scope_note="T13;只读桩,空结果集不报错(Q5/US-24)→ Designer 降级 T4/T5"),
|
||||
|
||||
# ---------------- 禁用(9,out_of_scope,仍注册在表) ----------------
|
||||
_t("blueprint.publish_auto", "全自主发布", "publish", TOOL_STATUS_DISABLED,
|
||||
PERM_PUBLISHING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 101,
|
||||
disable_reason="全自主发布禁止——Publish 必须人工审批(14.2/28/36 章);"
|
||||
"由 publish.request(require_approval=1)替代",
|
||||
scope_note="D1 out_of_scope"),
|
||||
|
||||
_t("curriculum.modify_auto", "自主改课", "blueprint", TOOL_STATUS_DISABLED,
|
||||
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 102,
|
||||
disable_reason="自主改课禁止(14.2)——Agent 只 Propose,修改经 blueprint.update "
|
||||
"且 Execute 受裁决",
|
||||
scope_note="D2 out_of_scope"),
|
||||
|
||||
_t("marketplace.create_listing", "Marketplace 上架", "marketplace",
|
||||
TOOL_STATUS_DISABLED, PERM_PUBLISHING, 0, [], 1,
|
||||
{"type": "object", "properties": {}}, {"type": "object", "properties": {}},
|
||||
"", 103,
|
||||
disable_reason="Marketplace 付费/订阅/分成归 Phase 4(22/23/35 章);"
|
||||
"本迭代发布仅可见性标记",
|
||||
scope_note="D3 out_of_scope(marketplace.* 全组禁用)"),
|
||||
|
||||
_t("kdb.write", "KDB 写入", "kdb", TOOL_STATUS_DISABLED, PERM_KDB, 0, [], 1,
|
||||
{"type": "object", "properties": {}}, {"type": "object", "properties": {}},
|
||||
"", 104,
|
||||
disable_reason="KDB 只读(owner Q5)——不建向量库/图谱,禁任何写入",
|
||||
scope_note="D4 out_of_scope"),
|
||||
|
||||
_t("research.collect", "Research 数据采集", "kdb", TOOL_STATUS_DISABLED,
|
||||
PERM_KDB, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 105,
|
||||
disable_reason="Research data 采集禁止(owner Q6)——任何学生数据不得进入 "
|
||||
"research 层;仅匿名聚合只读出口",
|
||||
scope_note="D5 out_of_scope"),
|
||||
|
||||
_t("experiment.ab_run", "A/B 实验执行", "experiments", TOOL_STATUS_DISABLED,
|
||||
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 106,
|
||||
disable_reason="自动 A/B 实验引擎归 Phase 3(33/35 章)",
|
||||
scope_note="D6 out_of_scope"),
|
||||
|
||||
_t("agent.mentor_invoke", "Mentor Agent 调用", "agent", TOOL_STATUS_DISABLED,
|
||||
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 107,
|
||||
disable_reason="Mentor Agent 归后续迭代(13.1/35 章 Phase 2)——本迭代仅 "
|
||||
"Designer+Critic",
|
||||
scope_note="D7 out_of_scope"),
|
||||
|
||||
_t("world.edit_3d", "3D 编辑器", "world", TOOL_STATUS_DISABLED,
|
||||
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 108,
|
||||
disable_reason="3D 编辑器禁止(owner Q1 / 35 章 Phase 0 Do not build)——"
|
||||
"JSON 驱动渲染替代",
|
||||
scope_note="D8 out_of_scope"),
|
||||
|
||||
_t("billing.charge", "计费扣款", "marketplace", TOOL_STATUS_DISABLED,
|
||||
PERM_PUBLISHING, 0, [], 1, {"type": "object", "properties": {}},
|
||||
{"type": "object", "properties": {}}, "", 109,
|
||||
disable_reason="计费/SSO/企业私有部署归 Phase 4(23.3)",
|
||||
scope_note="D9 out_of_scope(billing.* 全组禁用)"),
|
||||
]
|
||||
|
||||
ENABLED_TOOL_CODES = tuple(t["tool_code"] for t in TOOL_SEED
|
||||
if t["status"] == TOOL_STATUS_ENABLED)
|
||||
DISABLED_TOOL_CODES = tuple(t["tool_code"] for t in TOOL_SEED
|
||||
if t["status"] == TOOL_STATUS_DISABLED)
|
||||
|
||||
# 需强制人工审批的工具(require_approval=1)
|
||||
APPROVAL_REQUIRED_TOOLS = tuple(t["tool_code"] for t in TOOL_SEED
|
||||
if t["require_approval"])
|
||||
|
||||
|
||||
def _require_platform_admin(ctx):
|
||||
"""注册表治理仅 Platform Admin(agent-tool-contract.md §4.6)。"""
|
||||
if ctx is None or not ctx.has_perm(PERM_PLATFORM_ADMIN):
|
||||
fail(E_FORBIDDEN, "工具注册表治理仅 Platform Admin 可操作",
|
||||
actor=getattr(ctx, "actor_id", None))
|
||||
if ctx.actor_type != ACTOR_USER:
|
||||
fail(E_FORBIDDEN, "工具注册表治理仅人类用户可操作(Agent 不可自改注册表)",
|
||||
actor_type=ctx.actor_type)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Agent 定义 seed / 查询
|
||||
# ---------------------------------------------------------------------------
|
||||
def seed_agents(ctx=None, store=None):
|
||||
"""幂等注入 designer / critic 定义(按 tenant_id+agent_code upsert)。"""
|
||||
st = store or get_store()
|
||||
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
inserted, skipped = 0, 0
|
||||
for d in AGENT_DEFS:
|
||||
row = {
|
||||
"tenant_id": tenant_id,
|
||||
"agent_code": d["agent_code"],
|
||||
"agent_name": d["agent_name"],
|
||||
"agent_type": d["agent_type"],
|
||||
"loop_pattern": d["loop_pattern"],
|
||||
"authority_boundary": d["authority_boundary"],
|
||||
"write_allowed": d["write_allowed"],
|
||||
"allowed_tools": dumps(d["allowed_tools"]),
|
||||
"denied_tools": dumps(d["denied_tools"]),
|
||||
"model_binding": dumps(d["model_binding"]),
|
||||
"offline_fallback": d["offline_fallback"],
|
||||
"status": d["status"],
|
||||
"description": d["description"],
|
||||
"created_by": "seed",
|
||||
"created_at": now_ts(),
|
||||
"updated_at": now_ts(),
|
||||
}
|
||||
exist = one(st, "pbl_agent_def",
|
||||
{"tenant_id": tenant_id, "agent_code": d["agent_code"]})
|
||||
if exist:
|
||||
st.U("pbl_agent_def", row,
|
||||
{"tenant_id": tenant_id, "agent_code": d["agent_code"]})
|
||||
skipped += 1
|
||||
else:
|
||||
st.C("pbl_agent_def", row)
|
||||
inserted += 1
|
||||
return {"inserted": inserted, "skipped": skipped, "agents": [d["agent_code"] for d in AGENT_DEFS]}
|
||||
|
||||
|
||||
def get_agent_def(agent_code, ctx=None, store=None):
|
||||
st = store or get_store()
|
||||
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
row = one(st, "pbl_agent_def", {"tenant_id": tenant_id, "agent_code": agent_code})
|
||||
if row is None:
|
||||
# 回落到平台级定义(seed 用 __platform__ 时)
|
||||
row = one(st, "pbl_agent_def",
|
||||
{"tenant_id": "__platform__", "agent_code": agent_code})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "Agent 定义不存在: %s" % agent_code, agent_code=agent_code)
|
||||
row = dict(row)
|
||||
row["allowed_tools"] = loads(row.get("allowed_tools"), []) or []
|
||||
row["denied_tools"] = loads(row.get("denied_tools"), []) or []
|
||||
row["model_binding"] = loads(row.get("model_binding"), {}) or {}
|
||||
return row
|
||||
|
||||
|
||||
def list_agents(ctx=None, store=None):
|
||||
st = store or get_store()
|
||||
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
rows = st.R("pbl_agent_def", {"tenant_id": tenant_id}, order_by="id")
|
||||
if not rows:
|
||||
rows = st.R("pbl_agent_def", {"tenant_id": "__platform__"}, order_by="id")
|
||||
out = []
|
||||
for r in rows:
|
||||
r = dict(r)
|
||||
r["allowed_tools"] = loads(r.get("allowed_tools"), []) or []
|
||||
r["denied_tools"] = loads(r.get("denied_tools"), []) or []
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 工具注册表 CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
def register_tool(ctx, tool_code, tool_name, tool_group, status,
|
||||
required_permission, require_approval=0, input_schema=None,
|
||||
output_schema=None, disable_reason=None, allowed_agents=None,
|
||||
write_operation=0, backend_mapping="", approval_action_type=None,
|
||||
scope_note=None, store=None):
|
||||
"""
|
||||
注册工具(仅 Platform Admin)。重复 tool_code → PBL_E_DUPLICATE。
|
||||
disabled 必须给 disable_reason(审计可解释)。
|
||||
"""
|
||||
from .m4a_kernel import require_tenant
|
||||
tenant_id = require_tenant(ctx)
|
||||
_require_platform_admin(ctx)
|
||||
|
||||
if not tool_code or not isinstance(tool_code, str) or "." not in tool_code:
|
||||
from .m4a_kernel import E_VALIDATION
|
||||
fail(E_VALIDATION, "tool_code 非法(须为 group.name 形式)", tool_code=tool_code)
|
||||
if status not in (TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED):
|
||||
from .m4a_kernel import E_VALIDATION
|
||||
fail(E_VALIDATION, "status 仅 enabled/disabled", status=status)
|
||||
if status == TOOL_STATUS_DISABLED and not disable_reason:
|
||||
from .m4a_kernel import E_VALIDATION
|
||||
fail(E_VALIDATION, "禁用工具必须填写 disable_reason(out_of_scope 锚点)",
|
||||
tool_code=tool_code)
|
||||
|
||||
st = store or get_store()
|
||||
if one(st, "pbl_agent_tool", {"tenant_id": tenant_id, "tool_code": tool_code}):
|
||||
fail(E_DUPLICATE, "工具已注册: %s" % tool_code, tool_code=tool_code)
|
||||
|
||||
row = {
|
||||
"tenant_id": tenant_id, "tool_code": tool_code, "tool_name": tool_name,
|
||||
"tool_group": tool_group, "status": status,
|
||||
"required_permission": required_permission,
|
||||
"require_approval": 1 if require_approval else 0,
|
||||
"approval_action_type": approval_action_type,
|
||||
"allowed_agents": dumps(allowed_agents or []),
|
||||
"write_operation": 1 if write_operation else 0,
|
||||
"input_schema": dumps(input_schema or {"type": "object", "properties": {}}),
|
||||
"output_schema": dumps(output_schema or {"type": "object", "properties": {}}),
|
||||
"backend_mapping": backend_mapping or "",
|
||||
"disable_reason": disable_reason, "scope_note": scope_note,
|
||||
"sort_no": 900, "created_by": ctx.actor_id, "created_at": now_ts(),
|
||||
"updated_at": now_ts(),
|
||||
}
|
||||
tid = st.C("pbl_agent_tool", row)
|
||||
write_audit(ctx, "tool.register", "pbl_agent_tool", tid,
|
||||
{"tool_code": tool_code, "status": status}, store=st)
|
||||
row["id"] = tid
|
||||
return _decode_tool(row)
|
||||
|
||||
|
||||
def _decode_tool(row):
|
||||
r = dict(row)
|
||||
r["allowed_agents"] = loads(r.get("allowed_agents"), []) or []
|
||||
r["input_schema"] = loads(r.get("input_schema"), {}) or {}
|
||||
r["output_schema"] = loads(r.get("output_schema"), {}) or {}
|
||||
return r
|
||||
|
||||
|
||||
def get_tool(tool_code, ctx=None, store=None, tenant_id=None):
|
||||
"""取工具注册行(含禁用行)。找不到 → None(由裁决链 S3 判 default-deny)。"""
|
||||
st = store or get_store()
|
||||
tid = tenant_id or (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
row = one(st, "pbl_agent_tool", {"tenant_id": tid, "tool_code": tool_code})
|
||||
if row is None and tid != "__platform__":
|
||||
row = one(st, "pbl_agent_tool",
|
||||
{"tenant_id": "__platform__", "tool_code": tool_code})
|
||||
return _decode_tool(row) if row else None
|
||||
|
||||
|
||||
def list_tools(ctx=None, status=None, group=None, store=None, tenant_id=None):
|
||||
st = store or get_store()
|
||||
tid = tenant_id or (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
rows = st.R("pbl_agent_tool", {"tenant_id": tid}, order_by="sort_no")
|
||||
if not rows:
|
||||
rows = st.R("pbl_agent_tool", {"tenant_id": "__platform__"}, order_by="sort_no")
|
||||
out = [_decode_tool(r) for r in rows]
|
||||
if status:
|
||||
out = [t for t in out if t.get("status") == status]
|
||||
if group:
|
||||
out = [t for t in out if t.get("tool_group") == group]
|
||||
return out
|
||||
|
||||
|
||||
def set_tool_status(ctx, tool_code, status, reason=None, store=None):
|
||||
"""
|
||||
启停工具(仅 Platform Admin,人类)。属四类强制审批之 tool_registry_change:
|
||||
启用一个此前禁用的工具必须携带 approved 审批单号(reason 传 approval_no)。
|
||||
"""
|
||||
from .m4a_kernel import require_tenant, E_VALIDATION, E_STATE_ILLEGAL
|
||||
tenant_id = require_tenant(ctx)
|
||||
_require_platform_admin(ctx)
|
||||
if status not in (TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED):
|
||||
fail(E_VALIDATION, "status 仅 enabled/disabled", status=status)
|
||||
|
||||
st = store or get_store()
|
||||
row = get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id)
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "工具未注册: %s" % tool_code, tool_code=tool_code)
|
||||
|
||||
# 禁用→启用:强制人工审批(tool_registry_change)
|
||||
if row["status"] == TOOL_STATUS_DISABLED and status == TOOL_STATUS_ENABLED:
|
||||
from .m4a_approval import find_approved
|
||||
ap = find_approved("tool_registry_change", tool_code, ctx=ctx, store=st,
|
||||
tenant_id=tenant_id, approval_no=reason)
|
||||
if ap is None:
|
||||
fail(E_STATE_ILLEGAL,
|
||||
"启用禁用工具属 tool_registry_change 强制人工审批,缺少 approved 审批单",
|
||||
tool_code=tool_code, action_type="tool_registry_change")
|
||||
|
||||
if status == TOOL_STATUS_DISABLED and not reason:
|
||||
fail(E_VALIDATION, "禁用工具必须给出原因(写入 disable_reason)",
|
||||
tool_code=tool_code)
|
||||
|
||||
patch = {"status": status, "updated_at": now_ts()}
|
||||
if status == TOOL_STATUS_DISABLED:
|
||||
patch["disable_reason"] = reason
|
||||
st.U("pbl_agent_tool", patch, {"tenant_id": tenant_id, "tool_code": tool_code})
|
||||
write_audit(ctx, "tool.set_status", "pbl_agent_tool", row.get("id"),
|
||||
{"tool_code": tool_code, "from": row["status"], "to": status,
|
||||
"reason": reason}, store=st)
|
||||
return _decode_tool(get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id))
|
||||
|
||||
|
||||
def seed_tools(ctx=None, store=None):
|
||||
"""
|
||||
幂等注入 13 enabled + 9 disabled(build.sh 调用)。
|
||||
按 tenant_id + tool_code upsert:已存在则更新契约字段,不重复插入。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
|
||||
inserted, skipped = 0, 0
|
||||
for t in TOOL_SEED:
|
||||
row = {
|
||||
"tenant_id": tenant_id,
|
||||
"tool_code": t["tool_code"], "tool_name": t["tool_name"],
|
||||
"tool_group": t["tool_group"], "status": t["status"],
|
||||
"required_permission": t["required_permission"],
|
||||
"require_approval": t["require_approval"],
|
||||
"approval_action_type": t.get("approval_action_type"),
|
||||
"allowed_agents": dumps(t["allowed_agents"]),
|
||||
"write_operation": t["write_operation"],
|
||||
"input_schema": dumps(t["input_schema"]),
|
||||
"output_schema": dumps(t["output_schema"]),
|
||||
"backend_mapping": t["backend_mapping"],
|
||||
"disable_reason": t.get("disable_reason"),
|
||||
"scope_note": t.get("scope_note"),
|
||||
"sort_no": t["sort_no"], "created_by": "seed",
|
||||
"created_at": now_ts(), "updated_at": now_ts(),
|
||||
}
|
||||
exist = one(st, "pbl_agent_tool",
|
||||
{"tenant_id": tenant_id, "tool_code": t["tool_code"]})
|
||||
if exist:
|
||||
st.U("pbl_agent_tool", row,
|
||||
{"tenant_id": tenant_id, "tool_code": t["tool_code"]})
|
||||
skipped += 1
|
||||
else:
|
||||
st.C("pbl_agent_tool", row)
|
||||
inserted += 1
|
||||
return {
|
||||
"inserted": inserted, "skipped": skipped,
|
||||
"enabled": len(ENABLED_TOOL_CODES), "disabled": len(DISABLED_TOOL_CODES),
|
||||
"total": len(TOOL_SEED),
|
||||
"enabled_codes": list(ENABLED_TOOL_CODES),
|
||||
"disabled_codes": list(DISABLED_TOOL_CODES),
|
||||
"approval_required": list(APPROVAL_REQUIRED_TOOLS),
|
||||
}
|
||||
|
||||
|
||||
def registry_stats(ctx=None, store=None):
|
||||
"""注册表统计(供前端/审计核对 13+9)。"""
|
||||
tools = list_tools(ctx=ctx, store=store)
|
||||
en = [t for t in tools if t["status"] == TOOL_STATUS_ENABLED]
|
||||
dis = [t for t in tools if t["status"] == TOOL_STATUS_DISABLED]
|
||||
return {
|
||||
"total": len(tools), "enabled": len(en), "disabled": len(dis),
|
||||
"approval_required": [t["tool_code"] for t in en if t.get("require_approval")],
|
||||
"disabled_missing_reason": [t["tool_code"] for t in dis
|
||||
if not t.get("disable_reason")],
|
||||
"critic_writable_tools": [t["tool_code"] for t in en
|
||||
if t.get("write_operation")
|
||||
and CRITIC in (t.get("allowed_agents") or [])],
|
||||
}
|
||||
321
pbl_agent_runtime/m4a_tables.py
Normal file
321
pbl_agent_runtime/m4a_tables.py
Normal file
@ -0,0 +1,321 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 表定义(四段式:summary / fields / indexes / codes)
|
||||
|
||||
4 张自有表(data-model.md §F):
|
||||
pbl_agent_def Agent 定义(designer / critic,Critic 零写权限)
|
||||
pbl_agent_tool 工具注册表(13 enabled + 9 disabled)
|
||||
pbl_agent_trace 执行轨迹(第 28 章 7 要素,append-only)
|
||||
pbl_agent_approval 人工审批(四类强制审批)
|
||||
|
||||
抽象类型 → MySQL 物理类型由 to_sql() 生成;所有表 tenant_id 打头并进入联合索引首列。
|
||||
"""
|
||||
|
||||
from .m4a_kernel import TRACE_ELEMENTS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 抽象类型映射
|
||||
# ---------------------------------------------------------------------------
|
||||
TYPE_MAP = {
|
||||
"pk": "INT NOT NULL AUTO_INCREMENT",
|
||||
"tenant": "VARCHAR(64) NOT NULL",
|
||||
"str": "VARCHAR(255)",
|
||||
"str64": "VARCHAR(64)",
|
||||
"str128": "VARCHAR(128)",
|
||||
"code": "VARCHAR(128) NOT NULL",
|
||||
"text": "TEXT",
|
||||
"longtext": "LONGTEXT",
|
||||
"json": "LONGTEXT",
|
||||
"int": "INT",
|
||||
"bigint": "BIGINT",
|
||||
"bool": "TINYINT(1) NOT NULL DEFAULT 0",
|
||||
"decimal": "DECIMAL(5,4)",
|
||||
"datetime": "DATETIME",
|
||||
}
|
||||
|
||||
|
||||
def _f(name, type_, comment, not_null=False, default=None):
|
||||
return {
|
||||
"name": name,
|
||||
"type": type_,
|
||||
"comment": comment,
|
||||
"not_null": not_null,
|
||||
"default": default,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. pbl_agent_def — Agent 定义
|
||||
# ---------------------------------------------------------------------------
|
||||
TBL_AGENT_DEF = {
|
||||
"tblname": "pbl_agent_def",
|
||||
"summary": "Agent 定义表:本迭代仅 designer / critic 两个(第 13.1 章)。"
|
||||
"write_allowed=0 表示零写权限(Critic,14.1 不直接修改 Blueprint)。",
|
||||
"fields": [
|
||||
_f("id", "pk", "主键"),
|
||||
_f("tenant_id", "tenant", "租户ID(强制打头)", not_null=True),
|
||||
_f("agent_code", "code", "Agent 编码:designer / critic", not_null=True),
|
||||
_f("agent_name", "str128", "Agent 名称"),
|
||||
_f("agent_type", "str64", "类型:authoring(设计) / review(评审)"),
|
||||
_f("loop_pattern", "str128", "循环模式:observe_think_propose[_execute]"),
|
||||
_f("authority_boundary", "text", "权威边界说明(Agent 非真相源,14.1)"),
|
||||
_f("write_allowed", "bool", "是否允许写操作:designer=1 / critic=0", default=0),
|
||||
_f("allowed_tools", "json", "该 Agent 可调用的 tool_code 白名单(JSON 数组)"),
|
||||
_f("denied_tools", "json", "显式黑名单(JSON 数组,优先级高于 allowed)"),
|
||||
_f("model_binding", "json", "模型绑定:{model,capability,fallback}"),
|
||||
_f("offline_fallback", "str64", "模型不可达兜底策略:template_fallback / none"),
|
||||
_f("status", "str64", "状态:enabled / disabled", default="enabled"),
|
||||
_f("description", "text", "说明"),
|
||||
_f("created_by", "str64", "创建人"),
|
||||
_f("created_at", "datetime", "创建时间"),
|
||||
_f("updated_at", "datetime", "更新时间"),
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "uk_agent_def", "cols": ["tenant_id", "agent_code"], "unique": True},
|
||||
{"name": "idx_agent_def_status", "cols": ["tenant_id", "status"], "unique": False},
|
||||
],
|
||||
"codes": {
|
||||
"agent_code": ["designer", "critic"],
|
||||
"status": ["enabled", "disabled"],
|
||||
"write_allowed": [0, 1],
|
||||
"offline_fallback": ["template_fallback", "none"],
|
||||
},
|
||||
"append_only": False,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. pbl_agent_tool — 工具注册表
|
||||
# ---------------------------------------------------------------------------
|
||||
TBL_AGENT_TOOL = {
|
||||
"tblname": "pbl_agent_tool",
|
||||
"summary": "Agent 工具注册表:第 31 章裁剪子集(13 enabled + 9 disabled)。"
|
||||
"fail-closed default-deny:仅本表 enabled 记录可被 invoke_tool 执行;"
|
||||
"disabled 记录保留 disable_reason 供审计与拒绝回执。",
|
||||
"fields": [
|
||||
_f("id", "pk", "主键"),
|
||||
_f("tenant_id", "tenant", "租户ID(强制打头)", not_null=True),
|
||||
_f("tool_code", "code", "工具编码,如 blueprint.create", not_null=True),
|
||||
_f("tool_name", "str128", "工具名称"),
|
||||
_f("tool_group", "str64", "分组:blueprint/template/validation/critic/trace/"
|
||||
"approval/compile/kdb/publish/marketplace/experiments/agent/world"),
|
||||
_f("status", "str64", "状态:enabled / disabled", not_null=True, default="disabled"),
|
||||
_f("required_permission", "str128", "所需权限域:pbl_authoring / agent_tools / "
|
||||
"publishing / kdb / platform_admin"),
|
||||
_f("require_approval", "bool", "是否强制人工审批(14.2)", default=0),
|
||||
_f("approval_action_type", "str64", "强制审批类别:publish / compile_execute / "
|
||||
"blueprint_approve / tool_registry_change"),
|
||||
_f("allowed_agents", "json", "允许调用的 agent_code 列表(JSON 数组)"),
|
||||
_f("write_operation", "bool", "是否写操作(Critic 零写权限据此拒绝)", default=0),
|
||||
_f("input_schema", "json", "入参 JSON Schema(S8 契约校验)"),
|
||||
_f("output_schema", "json", "出参 JSON Schema"),
|
||||
_f("backend_mapping", "str", "后端权威服务映射,如 "
|
||||
"pbl_blueprint.create_blueprint"),
|
||||
_f("disable_reason", "text", "禁用原因(disabled 时必填,out_of_scope 锚点)"),
|
||||
_f("scope_note", "text", "范围备注(Phase / owner 决策锚点)"),
|
||||
_f("sort_no", "int", "排序号"),
|
||||
_f("created_by", "str64", "创建人"),
|
||||
_f("created_at", "datetime", "创建时间"),
|
||||
_f("updated_at", "datetime", "更新时间"),
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "uk_agent_tool", "cols": ["tenant_id", "tool_code"], "unique": True},
|
||||
{"name": "idx_tool_status", "cols": ["tenant_id", "status"], "unique": False},
|
||||
{"name": "idx_tool_group", "cols": ["tenant_id", "tool_group"], "unique": False},
|
||||
],
|
||||
"codes": {
|
||||
"status": ["enabled", "disabled"],
|
||||
"require_approval": [0, 1],
|
||||
"write_operation": [0, 1],
|
||||
"approval_action_type": [None, "publish", "compile_execute",
|
||||
"blueprint_approve", "tool_registry_change"],
|
||||
"required_permission": ["pbl_authoring", "agent_tools", "publishing",
|
||||
"kdb", "platform_admin"],
|
||||
},
|
||||
"append_only": False,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. pbl_agent_trace — 执行轨迹(append-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
_trace_fields = [
|
||||
_f("id", "pk", "主键"),
|
||||
_f("tenant_id", "tenant", "租户ID(强制打头)", not_null=True),
|
||||
_f("trace_no", "code", "轨迹编号(业务主键)", not_null=True),
|
||||
_f("agent_code", "str64", "产生轨迹的 Agent:designer / critic"),
|
||||
_f("session_no", "str128", "会话号(多轮对话归并)"),
|
||||
_f("status", "str64", "轨迹状态:open / done / deny", default="open"),
|
||||
_f("step_reached", "str64", "裁决链到达步:S1~S8 / EXECUTED"),
|
||||
_f("deny_code", "str64", "拒绝错误码(被拒时填)"),
|
||||
_f("deny_reason", "text", "拒绝原因(含 disable_reason / 越权说明)"),
|
||||
_f("input_context", "json", "① 输入上下文"),
|
||||
_f("retrieved_knowledge", "json", "② 检索到的知识(含 KDB 桩降级路径)"),
|
||||
_f("tool_calls", "json", "③ 工具调用(入参+出参,含被拒调用)"),
|
||||
_f("proposed_action", "json", "④ 提案动作(Agent 只 Propose,14.1)"),
|
||||
_f("result", "json", "⑤ 执行结果(allow/deny + 结果体)"),
|
||||
_f("approval", "json", "⑥ 审批信息(approval_no/status/approver)"),
|
||||
_f("final_output", "json", "⑦ 最终输出"),
|
||||
_f("created_by", "str64", "创建人"),
|
||||
_f("created_at", "datetime", "创建时间"),
|
||||
_f("appended_at", "datetime", "最后追加时间"),
|
||||
]
|
||||
|
||||
TBL_AGENT_TRACE = {
|
||||
"tblname": "pbl_agent_trace",
|
||||
"summary": "Agent 执行轨迹(第 28 章 7 要素,US-20 可查不可篡改)。"
|
||||
"append-only:无 update/delete 对外接口,DB 层 append_only 标记,"
|
||||
"要素只能追加;已完成(done/deny)轨迹再追加 → PBL_E_APPEND_ONLY。",
|
||||
"fields": _trace_fields,
|
||||
"indexes": [
|
||||
{"name": "uk_trace_no", "cols": ["tenant_id", "trace_no"], "unique": True},
|
||||
{"name": "idx_trace_agent", "cols": ["tenant_id", "agent_code", "created_at"],
|
||||
"unique": False},
|
||||
{"name": "idx_trace_session", "cols": ["tenant_id", "session_no"], "unique": False},
|
||||
{"name": "idx_trace_status", "cols": ["tenant_id", "status"], "unique": False},
|
||||
],
|
||||
"codes": {
|
||||
"status": ["open", "done", "deny"],
|
||||
"agent_code": ["designer", "critic"],
|
||||
"step_reached": [s[0] for s in ()] + ["S1", "S2", "S3", "S4", "S5", "S6", "S7",
|
||||
"S8", "EXECUTED"],
|
||||
},
|
||||
"append_only": True,
|
||||
}
|
||||
|
||||
# 轨迹要素追加流水(append-only 明细,逐条可查,支撑 US-20 完整性举证)
|
||||
TBL_TRACE_STAGE = {
|
||||
"tblname": "pbl_agent_trace_stage",
|
||||
"summary": "轨迹要素追加流水:每次 append_trace 落一行,只增不改不删,"
|
||||
"用于举证 7 要素逐条可查与追加顺序(US-20)。",
|
||||
"fields": [
|
||||
_f("id", "pk", "主键"),
|
||||
_f("tenant_id", "tenant", "租户ID", not_null=True),
|
||||
_f("trace_no", "code", "轨迹编号", not_null=True),
|
||||
_f("stage", "str64", "要素名(7 要素之一)", not_null=True),
|
||||
_f("payload", "json", "要素内容"),
|
||||
_f("seq_no", "int", "追加序号(同轨迹内自增)"),
|
||||
_f("created_at", "datetime", "追加时间"),
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_stage_trace", "cols": ["tenant_id", "trace_no", "seq_no"],
|
||||
"unique": False},
|
||||
],
|
||||
"codes": {"stage": list(TRACE_ELEMENTS)},
|
||||
"append_only": True,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. pbl_agent_approval — 人工审批
|
||||
# ---------------------------------------------------------------------------
|
||||
TBL_AGENT_APPROVAL = {
|
||||
"tblname": "pbl_agent_approval",
|
||||
"summary": "人工审批单(14.2):四类强制审批 publish / compile_execute / "
|
||||
"blueprint_approve / tool_registry_change。Agent 不可自批:"
|
||||
"decide_approval 仅 actor_type=user 可调用。",
|
||||
"fields": [
|
||||
_f("id", "pk", "主键"),
|
||||
_f("tenant_id", "tenant", "租户ID(强制打头)", not_null=True),
|
||||
_f("approval_no", "code", "审批单号(业务主键)", not_null=True),
|
||||
_f("trace_id", "int", "关联轨迹ID"),
|
||||
_f("trace_no", "str128", "关联轨迹编号"),
|
||||
_f("agent_code", "str64", "提案 Agent"),
|
||||
_f("action_type", "str64", "审批类别(四类之一)", not_null=True),
|
||||
_f("tool_code", "str128", "触发审批的工具编码"),
|
||||
_f("object_type", "str64", "对象类型:blueprint / tool / compile_task"),
|
||||
_f("object_id", "str128", "对象ID"),
|
||||
_f("action_payload", "json", "提案内容快照(审批人据此判断)"),
|
||||
_f("status", "str64", "状态:pending / approved / rejected / expired",
|
||||
not_null=True, default="pending"),
|
||||
_f("requester_type", "str64", "提案方类型:agent / user"),
|
||||
_f("requester_id", "str64", "提案方ID"),
|
||||
_f("approver_id", "str64", "指定审批人(可空=任一授权人)"),
|
||||
_f("approver_type", "str64", "实际审批人类型(必须 user)"),
|
||||
_f("decided_by", "str64", "实际审批人ID"),
|
||||
_f("decided_at", "datetime", "审批时间"),
|
||||
_f("comment", "text", "审批意见"),
|
||||
_f("expires_at", "datetime", "过期时间(过期后 pending 视为无效)"),
|
||||
_f("created_at", "datetime", "创建时间"),
|
||||
_f("updated_at", "datetime", "更新时间"),
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "uk_approval_no", "cols": ["tenant_id", "approval_no"], "unique": True},
|
||||
{"name": "idx_approval_pending", "cols": ["tenant_id", "status", "approver_id"],
|
||||
"unique": False},
|
||||
{"name": "idx_approval_trace", "cols": ["tenant_id", "trace_no"], "unique": False},
|
||||
{"name": "idx_approval_action", "cols": ["tenant_id", "action_type", "status"],
|
||||
"unique": False},
|
||||
],
|
||||
"codes": {
|
||||
"status": ["pending", "approved", "rejected", "expired"],
|
||||
"action_type": ["publish", "compile_execute", "blueprint_approve",
|
||||
"tool_registry_change"],
|
||||
"requester_type": ["agent", "user", "system"],
|
||||
"approver_type": ["user"],
|
||||
},
|
||||
"append_only": False,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 表清单 + DDL 生成
|
||||
# ---------------------------------------------------------------------------
|
||||
TABLES = (TBL_AGENT_DEF, TBL_AGENT_TOOL, TBL_AGENT_TRACE, TBL_TRACE_STAGE,
|
||||
TBL_AGENT_APPROVAL)
|
||||
|
||||
APPEND_ONLY_TABLES = tuple(t["tblname"] for t in TABLES if t.get("append_only"))
|
||||
|
||||
|
||||
def _col_sql(f):
|
||||
phys = TYPE_MAP.get(f["type"])
|
||||
if phys is None:
|
||||
raise ValueError("未知抽象类型: %s(字段 %s)" % (f["type"], f["name"]))
|
||||
if f["type"] == "pk":
|
||||
return " `id` %s COMMENT '%s'" % (phys, f["comment"])
|
||||
parts = [" `%s`" % f["name"], phys]
|
||||
if f.get("not_null") and "NOT NULL" not in phys:
|
||||
parts.append("NOT NULL")
|
||||
if f.get("default") is not None:
|
||||
dv = f["default"]
|
||||
parts.append("DEFAULT %s" % (dv if isinstance(dv, int) else "'%s'" % dv))
|
||||
parts.append("COMMENT '%s'" % f["comment"].replace("'", "''"))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def to_sql(tbl, engine="mysql"):
|
||||
"""四段式表定义 → CREATE TABLE DDL(含 append-only 表注释标记)。"""
|
||||
lines = ["CREATE TABLE IF NOT EXISTS `%s` (" % tbl["tblname"]]
|
||||
cols = [_col_sql(f) for f in tbl["fields"]]
|
||||
if tbl["fields"][0]["type"] == "pk":
|
||||
cols.append(" PRIMARY KEY (`id`)")
|
||||
for idx in tbl.get("indexes", []):
|
||||
kw = "UNIQUE KEY" if idx.get("unique") else "KEY"
|
||||
cols.append(" %s `%s` (%s)" % (
|
||||
kw, idx["name"], ", ".join("`%s`" % c for c in idx["cols"])))
|
||||
lines.append(",\n".join(cols))
|
||||
comment = tbl["summary"].replace("'", "''")
|
||||
if tbl.get("append_only"):
|
||||
comment += " [APPEND-ONLY: 禁止 UPDATE/DELETE]"
|
||||
lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';" % comment)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def all_sql():
|
||||
return "\n\n".join(to_sql(t) for t in TABLES)
|
||||
|
||||
|
||||
def ensure_tables(store=None):
|
||||
"""
|
||||
建表(幂等)。sqlor 后端走 DDL;MemoryStore 直接建空表。
|
||||
"""
|
||||
from .m4a_kernel import get_store
|
||||
st = store or get_store()
|
||||
created = []
|
||||
for tbl in TABLES:
|
||||
try:
|
||||
st.sqlExe(to_sql(tbl))
|
||||
created.append(tbl["tblname"])
|
||||
except NotImplementedError:
|
||||
st.R(tbl["tblname"]) # MemoryStore:触碰即建表
|
||||
created.append(tbl["tblname"])
|
||||
except Exception:
|
||||
# 表已存在等场景,幂等忽略
|
||||
created.append(tbl["tblname"])
|
||||
return {"created": created, "append_only": list(APPEND_ONLY_TABLES)}
|
||||
267
pbl_agent_runtime/m4a_trace.py
Normal file
267
pbl_agent_runtime/m4a_trace.py
Normal file
@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_agent_runtime M4a — 执行轨迹(第 28 章 7 要素,US-20 可查不可篡改)
|
||||
|
||||
约束:
|
||||
* append-only:无 update/delete 对外接口;要素只能追加
|
||||
* 已完成(done/deny)轨迹再追加 → PBL_E_APPEND_ONLY
|
||||
* get_trace 仅 Platform Admin / owner.audit / 轨迹所属 Agent 自身可读,
|
||||
读他人轨迹 → PBL_E_FORBIDDEN
|
||||
* 7 要素齐备性可校验(trace_completeness)
|
||||
"""
|
||||
|
||||
from .m4a_kernel import (
|
||||
get_store, one, fail, now_ts, dumps, loads, gen_no,
|
||||
E_NOT_FOUND, E_FORBIDDEN, E_APPEND_ONLY, E_VALIDATION, E_TENANT_MISSING,
|
||||
TRACE_ELEMENTS, TRACE_STATUS_OPEN, TRACE_STATUS_DONE, TRACE_STATUS_DENY,
|
||||
ACTOR_USER,
|
||||
)
|
||||
|
||||
PERM_TRACE_AUDIT = ("platform_admin", "owner.audit")
|
||||
|
||||
|
||||
def _tenant(ctx):
|
||||
if ctx is None or not getattr(ctx, "tenant_id", None):
|
||||
fail(E_TENANT_MISSING, "轨迹操作缺少租户上下文")
|
||||
return str(ctx.tenant_id).strip()
|
||||
|
||||
|
||||
def _can_read(ctx, row):
|
||||
"""Platform Admin / owner.audit 全量可读;Agent 仅可读自身轨迹。"""
|
||||
if ctx is None:
|
||||
return False
|
||||
for p in PERM_TRACE_AUDIT:
|
||||
if ctx.has_perm(p):
|
||||
return True
|
||||
if ctx.actor_type == ACTOR_USER:
|
||||
return True
|
||||
# Agent 自身:actor_id 记的是 agent_code
|
||||
if str(ctx.actor_id or "") == str(row.get("agent_code") or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 写:开轨迹 / 追加要素
|
||||
# ---------------------------------------------------------------------------
|
||||
def start_trace(agent_code, session_no=None, input_context=None, ctx=None,
|
||||
store=None, trace_no=None):
|
||||
"""开一条轨迹,写入要素① input_context,返回 trace_no。"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
no = trace_no or gen_no("TRC")
|
||||
if one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": no}):
|
||||
fail(E_VALIDATION, "trace_no 已存在: %s" % no, trace_no=no)
|
||||
row = {
|
||||
"tenant_id": tenant_id, "trace_no": no, "agent_code": agent_code,
|
||||
"session_no": session_no, "status": TRACE_STATUS_OPEN, "step_reached": None,
|
||||
"deny_code": None, "deny_reason": None,
|
||||
"input_context": dumps(input_context or {}),
|
||||
"retrieved_knowledge": dumps([]),
|
||||
"tool_calls": dumps([]),
|
||||
"proposed_action": dumps(None),
|
||||
"result": dumps(None),
|
||||
"approval": dumps(None),
|
||||
"final_output": dumps(None),
|
||||
"created_by": getattr(ctx, "actor_id", None),
|
||||
"created_at": now_ts(), "appended_at": now_ts(),
|
||||
}
|
||||
tid = st.C("pbl_agent_trace", row)
|
||||
# 要素流水(append-only 明细)
|
||||
_log_stage(st, tenant_id, no, "input_context", input_context or {}, 1)
|
||||
return no
|
||||
|
||||
|
||||
def _log_stage(st, tenant_id, trace_no, stage, payload, seq_no):
|
||||
st.C("pbl_agent_trace_stage", {
|
||||
"tenant_id": tenant_id, "trace_no": trace_no, "stage": stage,
|
||||
"payload": dumps(payload), "seq_no": seq_no, "created_at": now_ts(),
|
||||
})
|
||||
|
||||
|
||||
def _next_seq(st, tenant_id, trace_no):
|
||||
rows = st.R("pbl_agent_trace_stage",
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="-seq_no",
|
||||
limit=1)
|
||||
return (rows[0].get("seq_no") or 0) + 1 if rows else 1
|
||||
|
||||
|
||||
def append_trace(trace_no, stage, payload, ctx=None, store=None):
|
||||
"""
|
||||
追加一个要素(append-only)。
|
||||
- stage 必须是 7 要素之一,否则 PBL_E_VALIDATION
|
||||
- 轨迹已 done/deny → PBL_E_APPEND_ONLY
|
||||
- tool_calls / retrieved_knowledge 为列表型要素,追加即 append 到数组
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
if stage not in TRACE_ELEMENTS:
|
||||
fail(E_VALIDATION, "stage 必须是 7 要素之一: %s" % (TRACE_ELEMENTS,),
|
||||
stage=stage)
|
||||
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
|
||||
if row.get("status") in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
|
||||
fail(E_APPEND_ONLY,
|
||||
"轨迹已完成(status=%s),append-only 不可再追加" % row.get("status"),
|
||||
trace_no=trace_no, stage=stage)
|
||||
|
||||
list_stages = ("tool_calls", "retrieved_knowledge")
|
||||
if stage in list_stages:
|
||||
cur = loads(row.get(stage), []) or []
|
||||
if not isinstance(cur, list):
|
||||
cur = [cur] if cur else []
|
||||
cur = cur + [payload]
|
||||
new_val = dumps(cur)
|
||||
elif stage == "input_context":
|
||||
cur = loads(row.get(stage), {}) or {}
|
||||
if isinstance(cur, dict) and isinstance(payload, dict):
|
||||
merged = dict(cur)
|
||||
merged.update(payload)
|
||||
new_val = dumps(merged)
|
||||
else:
|
||||
new_val = dumps(payload)
|
||||
else:
|
||||
new_val = dumps(payload)
|
||||
|
||||
with st.mutable() as raw:
|
||||
raw.U("pbl_agent_trace", {stage: new_val, "appended_at": now_ts()},
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
_log_stage(st, tenant_id, trace_no, stage, payload,
|
||||
_next_seq(st, tenant_id, trace_no))
|
||||
return {"trace_no": trace_no, "stage": stage, "appended_at": now_ts()}
|
||||
|
||||
|
||||
def finish_trace(trace_no, status=TRACE_STATUS_DONE, step_reached=None,
|
||||
deny_code=None, deny_reason=None, ctx=None, store=None):
|
||||
"""
|
||||
收口轨迹状态(受控通道,非要素修改)。status: done / deny。
|
||||
收口后轨迹进入 append-only 冻结态。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
|
||||
if row.get("status") in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
|
||||
return {"trace_no": trace_no, "status": row.get("status"), "already": True}
|
||||
if status not in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
|
||||
fail(E_VALIDATION, "收口状态仅 done/deny", status=status)
|
||||
patch = {"status": status, "appended_at": now_ts()}
|
||||
if step_reached:
|
||||
patch["step_reached"] = step_reached
|
||||
if deny_code:
|
||||
patch["deny_code"] = deny_code
|
||||
if deny_reason:
|
||||
patch["deny_reason"] = deny_reason
|
||||
with st.mutable() as raw:
|
||||
raw.U("pbl_agent_trace", patch,
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
return {"trace_no": trace_no, "status": status, "step_reached": step_reached}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 读:单条 / 列表 / 完整性
|
||||
# ---------------------------------------------------------------------------
|
||||
def _decode(row):
|
||||
r = dict(row)
|
||||
for e in TRACE_ELEMENTS:
|
||||
default = [] if e in ("tool_calls", "retrieved_knowledge") else None
|
||||
r[e] = loads(r.get(e), default if default is not None else {})
|
||||
if e in ("tool_calls", "retrieved_knowledge") and not isinstance(r[e], list):
|
||||
r[e] = [] if r[e] in (None, "") else [r[e]]
|
||||
return r
|
||||
|
||||
|
||||
def get_trace(trace_no, ctx=None, store=None, internal=False):
|
||||
"""
|
||||
读轨迹(7 要素完整)。
|
||||
internal=True 供内核(invoke_tool)使用,跳过读权限校验(同进程内已鉴权)。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
|
||||
if not internal and not _can_read(ctx, row):
|
||||
fail(E_FORBIDDEN,
|
||||
"无权读取该轨迹(仅 Platform Admin / owner.audit / 轨迹所属 Agent 自身)",
|
||||
trace_no=trace_no, agent_code=row.get("agent_code"))
|
||||
return _decode(row)
|
||||
|
||||
|
||||
def trace_completeness(trace_no, ctx=None, store=None):
|
||||
"""7 要素齐备性核验(US-20 举证):返回每要素是否已写入 + 追加流水条数。"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
row = get_trace(trace_no, ctx=ctx, store=st, internal=True)
|
||||
stages = st.R("pbl_agent_trace_stage",
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="seq_no")
|
||||
filled = {}
|
||||
for e in TRACE_ELEMENTS:
|
||||
v = row.get(e)
|
||||
filled[e] = bool(v not in (None, "", [], {}))
|
||||
return {
|
||||
"trace_no": trace_no,
|
||||
"status": row.get("status"),
|
||||
"step_reached": row.get("step_reached"),
|
||||
"elements": TRACE_ELEMENTS,
|
||||
"filled": filled,
|
||||
"filled_count": sum(1 for v in filled.values() if v),
|
||||
"complete": all(filled.values()),
|
||||
"stage_log_count": len(stages),
|
||||
"stage_log": [{"seq_no": s.get("seq_no"), "stage": s.get("stage"),
|
||||
"created_at": s.get("created_at")} for s in stages],
|
||||
}
|
||||
|
||||
|
||||
def list_traces(filters=None, page=1, size=20, ctx=None, store=None):
|
||||
"""轨迹分页查询(F-AG-04)。仅 Platform Admin / owner.audit / 人类用户。"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
if not _can_read(ctx, {"agent_code": None}):
|
||||
fail(E_FORBIDDEN, "无权查询轨迹列表(仅 Platform Admin / owner.audit)")
|
||||
filters = filters or {}
|
||||
where = {"tenant_id": tenant_id}
|
||||
for k in ("agent_code", "session_no", "status", "step_reached", "deny_code"):
|
||||
if filters.get(k):
|
||||
where[k] = filters[k]
|
||||
rows = st.R("pbl_agent_trace", where, order_by="-id")
|
||||
tr = filters.get("time_range") or {}
|
||||
if tr.get("start"):
|
||||
rows = [r for r in rows if (r.get("created_at") or "") >= tr["start"]]
|
||||
if tr.get("end"):
|
||||
rows = [r for r in rows if (r.get("created_at") or "") <= tr["end"]]
|
||||
total = len(rows)
|
||||
page = max(1, int(page or 1))
|
||||
size = max(1, min(200, int(size or 20)))
|
||||
part = rows[(page - 1) * size: page * size]
|
||||
return {"items": [_decode(r) for r in part], "total": total,
|
||||
"page": page, "size": size}
|
||||
|
||||
|
||||
def list_trace_stages(trace_no, ctx=None, store=None):
|
||||
"""要素追加流水(只读,逐条可查)。"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
if row is None:
|
||||
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
|
||||
if not _can_read(ctx, row):
|
||||
fail(E_FORBIDDEN, "无权读取该轨迹流水", trace_no=trace_no)
|
||||
rows = st.R("pbl_agent_trace_stage",
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="seq_no")
|
||||
return [{"seq_no": r.get("seq_no"), "stage": r.get("stage"),
|
||||
"payload": loads(r.get("payload"), None),
|
||||
"created_at": r.get("created_at")} for r in rows]
|
||||
|
||||
|
||||
def try_mutate_trace(trace_no, patch, ctx=None, store=None):
|
||||
"""
|
||||
反向验证入口(测试锚点):任何对轨迹的直接 update 都必须被 append-only 守卫拒绝。
|
||||
正常业务代码不得调用;用于 US-20「不可篡改」举证。
|
||||
"""
|
||||
st = store or get_store()
|
||||
tenant_id = _tenant(ctx)
|
||||
return st.U("pbl_agent_trace", patch,
|
||||
{"tenant_id": tenant_id, "trace_no": trace_no})
|
||||
447
scripts/m4a_selftest.py
Normal file
447
scripts/m4a_selftest.py
Normal file
@ -0,0 +1,447 @@
|
||||
# -*- 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())
|
||||
89
scripts/seed_m4a.py
Normal file
89
scripts/seed_m4a.py
Normal file
@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
M4a seed 脚本(build.sh / 部署时调用,幂等)
|
||||
|
||||
用法:
|
||||
python3 scripts/seed_m4a.py # 平台级 seed(tenant_id=__platform__)
|
||||
python3 scripts/seed_m4a.py --tenant T001 # 指定租户 seed
|
||||
python3 scripts/seed_m4a.py --memory # 离线内存库演练(不连 DB)
|
||||
|
||||
动作:建表(4 主表 + 1 append-only 流水表)→ seed_agents(designer/critic)
|
||||
→ seed_tools(13 enabled + 9 disabled)→ 打印自检结果
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from pbl_agent_runtime.m4a_init import load_pbl_agent_runtime_m4a # noqa: E402
|
||||
from pbl_agent_runtime.m4a_kernel import get_store, MemoryStore, set_store # noqa: E402
|
||||
from pbl_agent_runtime.m4a import M4aApi, make_admin_ctx # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tenant", default=None, help="租户ID(缺省=平台级 __platform__)")
|
||||
ap.add_argument("--memory", action="store_true", help="使用内存库演练(不连 DB)")
|
||||
args = ap.parse_args()
|
||||
|
||||
store = None
|
||||
if args.memory:
|
||||
store = set_store(MemoryStore())
|
||||
else:
|
||||
store = get_store()
|
||||
|
||||
ret = load_pbl_agent_runtime_m4a(tenant_id=args.tenant, store=store)
|
||||
api = M4aApi(tenant_id=args.tenant or "__platform__", store=store)
|
||||
sc = api.self_check()
|
||||
|
||||
print("=" * 72)
|
||||
print("pbl_agent_runtime M4a seed 结果")
|
||||
print("=" * 72)
|
||||
print("module : %s (%s)" % (ret["module"], ret["part"]))
|
||||
print("dbname : %s" % ret["dbname"])
|
||||
print("tables : %s" % ", ".join(t for t in ret["tables"]["created"]))
|
||||
print("append_only : %s" % ", ".join(ret["append_only_tables"]))
|
||||
print("agents seed : %s" % json.dumps(ret["seed"]["agents"],
|
||||
ensure_ascii=False))
|
||||
print("tools seed : inserted=%s skipped=%s enabled=%s disabled=%s total=%s"
|
||||
% (ret["seed"]["tools"]["inserted"], ret["seed"]["tools"]["skipped"],
|
||||
ret["seed"]["tools"]["enabled"], ret["seed"]["tools"]["disabled"],
|
||||
ret["seed"]["tools"]["total"]))
|
||||
print("enabled (13) : %s" % ", ".join(ret["enabled_tools"]))
|
||||
print("disabled (9) : %s" % ", ".join(ret["disabled_tools"]))
|
||||
print("approval needed : %s" % ", ".join(ret["approval_required_tools"]))
|
||||
print("-" * 72)
|
||||
print("self_check.agents : %s"
|
||||
% json.dumps([{"code": a["agent_code"], "write": a["write_allowed"]}
|
||||
for a in sc["agents"]], ensure_ascii=False))
|
||||
print("self_check.critic_write : %s (须为空=Critic 零写权限)"
|
||||
% sc["critic_write_allowed"])
|
||||
print("self_check.tools : %s" % json.dumps(
|
||||
{k: v for k, v in sc["tools"].items()
|
||||
if k in ("total", "enabled", "disabled", "approval_required",
|
||||
"disabled_missing_reason", "critic_writable_tools")},
|
||||
ensure_ascii=False))
|
||||
print("self_check.adjudication : %s (%d 步)"
|
||||
% ("→".join(sc["adjudication_steps"]), sc["adjudication_step_count"]))
|
||||
print("self_check.approvals : %s" % ", ".join(sc["mandatory_approvals"]))
|
||||
print("self_check.trace_elements: %s" % ", ".join(sc["trace_elements"]))
|
||||
print("-" * 72)
|
||||
ok = (sc["agent_count"] == 2 and not sc["critic_write_allowed"]
|
||||
and sc["tools"]["total"] == 22 and sc["tools"]["enabled"] == 13
|
||||
and sc["tools"]["disabled"] == 9
|
||||
and not sc["tools"]["disabled_missing_reason"]
|
||||
and not sc["tools"]["critic_writable_tools"]
|
||||
and sc["adjudication_step_count"] == 8
|
||||
and len(sc["mandatory_approvals"]) == 4
|
||||
and len(sc["trace_elements"]) == 7)
|
||||
print("M4a seed 门禁: %s" % ("PASS" if ok else "FAIL"))
|
||||
print("backend availability: %s" % json.dumps(
|
||||
{k: v["available"] for k, v in sc["backend"].items()}, ensure_ascii=False))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
123
sql/m4a_ddl.sql
Normal file
123
sql/m4a_ddl.sql
Normal file
@ -0,0 +1,123 @@
|
||||
-- pbl_agent_runtime M4a DDL(自动生成,勿手改)
|
||||
-- 生成源:pbl_agent_runtime/m4a_tables.py(四段式表定义 → to_sql)
|
||||
-- 表:pbl_agent_def / pbl_agent_tool / pbl_agent_trace(append-only) / pbl_agent_trace_stage(append-only) / pbl_agent_approval
|
||||
-- 工具注册表 seed:13 enabled + 9 disabled(scripts/seed_m4a.py 幂等注入,build.sh 调用)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_agent_def` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制打头)',
|
||||
`agent_code` VARCHAR(128) NOT NULL COMMENT 'Agent 编码:designer / critic',
|
||||
`agent_name` VARCHAR(128) COMMENT 'Agent 名称',
|
||||
`agent_type` VARCHAR(64) COMMENT '类型:authoring(设计) / review(评审)',
|
||||
`loop_pattern` VARCHAR(128) COMMENT '循环模式:observe_think_propose[_execute]',
|
||||
`authority_boundary` TEXT COMMENT '权威边界说明(Agent 非真相源,14.1)',
|
||||
`write_allowed` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否允许写操作:designer=1 / critic=0',
|
||||
`allowed_tools` LONGTEXT COMMENT '该 Agent 可调用的 tool_code 白名单(JSON 数组)',
|
||||
`denied_tools` LONGTEXT COMMENT '显式黑名单(JSON 数组,优先级高于 allowed)',
|
||||
`model_binding` LONGTEXT COMMENT '模型绑定:{model,capability,fallback}',
|
||||
`offline_fallback` VARCHAR(64) COMMENT '模型不可达兜底策略:template_fallback / none',
|
||||
`status` VARCHAR(64) DEFAULT 'enabled' COMMENT '状态:enabled / disabled',
|
||||
`description` TEXT COMMENT '说明',
|
||||
`created_by` VARCHAR(64) COMMENT '创建人',
|
||||
`created_at` DATETIME COMMENT '创建时间',
|
||||
`updated_at` DATETIME COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_def` (`tenant_id`, `agent_code`),
|
||||
KEY `idx_agent_def_status` (`tenant_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 定义表:本迭代仅 designer / critic 两个(第 13.1 章)。write_allowed=0 表示零写权限(Critic,14.1 不直接修改 Blueprint)。';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_agent_tool` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制打头)',
|
||||
`tool_code` VARCHAR(128) NOT NULL COMMENT '工具编码,如 blueprint.create',
|
||||
`tool_name` VARCHAR(128) COMMENT '工具名称',
|
||||
`tool_group` VARCHAR(64) COMMENT '分组:blueprint/template/validation/critic/trace/approval/compile/kdb/publish/marketplace/experiments/agent/world',
|
||||
`status` VARCHAR(64) NOT NULL DEFAULT 'disabled' COMMENT '状态:enabled / disabled',
|
||||
`required_permission` VARCHAR(128) COMMENT '所需权限域:pbl_authoring / agent_tools / publishing / kdb / platform_admin',
|
||||
`require_approval` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否强制人工审批(14.2)',
|
||||
`approval_action_type` VARCHAR(64) COMMENT '强制审批类别:publish / compile_execute / blueprint_approve / tool_registry_change',
|
||||
`allowed_agents` LONGTEXT COMMENT '允许调用的 agent_code 列表(JSON 数组)',
|
||||
`write_operation` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否写操作(Critic 零写权限据此拒绝)',
|
||||
`input_schema` LONGTEXT COMMENT '入参 JSON Schema(S8 契约校验)',
|
||||
`output_schema` LONGTEXT COMMENT '出参 JSON Schema',
|
||||
`backend_mapping` VARCHAR(255) COMMENT '后端权威服务映射,如 pbl_blueprint.create_blueprint',
|
||||
`disable_reason` TEXT COMMENT '禁用原因(disabled 时必填,out_of_scope 锚点)',
|
||||
`scope_note` TEXT COMMENT '范围备注(Phase / owner 决策锚点)',
|
||||
`sort_no` INT COMMENT '排序号',
|
||||
`created_by` VARCHAR(64) COMMENT '创建人',
|
||||
`created_at` DATETIME COMMENT '创建时间',
|
||||
`updated_at` DATETIME COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_tool` (`tenant_id`, `tool_code`),
|
||||
KEY `idx_tool_status` (`tenant_id`, `status`),
|
||||
KEY `idx_tool_group` (`tenant_id`, `tool_group`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 工具注册表:第 31 章裁剪子集(13 enabled + 9 disabled)。fail-closed default-deny:仅本表 enabled 记录可被 invoke_tool 执行;disabled 记录保留 disable_reason 供审计与拒绝回执。';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_agent_trace` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制打头)',
|
||||
`trace_no` VARCHAR(128) NOT NULL COMMENT '轨迹编号(业务主键)',
|
||||
`agent_code` VARCHAR(64) COMMENT '产生轨迹的 Agent:designer / critic',
|
||||
`session_no` VARCHAR(128) COMMENT '会话号(多轮对话归并)',
|
||||
`status` VARCHAR(64) DEFAULT 'open' COMMENT '轨迹状态:open / done / deny',
|
||||
`step_reached` VARCHAR(64) COMMENT '裁决链到达步:S1~S8 / EXECUTED',
|
||||
`deny_code` VARCHAR(64) COMMENT '拒绝错误码(被拒时填)',
|
||||
`deny_reason` TEXT COMMENT '拒绝原因(含 disable_reason / 越权说明)',
|
||||
`input_context` LONGTEXT COMMENT '① 输入上下文',
|
||||
`retrieved_knowledge` LONGTEXT COMMENT '② 检索到的知识(含 KDB 桩降级路径)',
|
||||
`tool_calls` LONGTEXT COMMENT '③ 工具调用(入参+出参,含被拒调用)',
|
||||
`proposed_action` LONGTEXT COMMENT '④ 提案动作(Agent 只 Propose,14.1)',
|
||||
`result` LONGTEXT COMMENT '⑤ 执行结果(allow/deny + 结果体)',
|
||||
`approval` LONGTEXT COMMENT '⑥ 审批信息(approval_no/status/approver)',
|
||||
`final_output` LONGTEXT COMMENT '⑦ 最终输出',
|
||||
`created_by` VARCHAR(64) COMMENT '创建人',
|
||||
`created_at` DATETIME COMMENT '创建时间',
|
||||
`appended_at` DATETIME COMMENT '最后追加时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_trace_no` (`tenant_id`, `trace_no`),
|
||||
KEY `idx_trace_agent` (`tenant_id`, `agent_code`, `created_at`),
|
||||
KEY `idx_trace_session` (`tenant_id`, `session_no`),
|
||||
KEY `idx_trace_status` (`tenant_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 执行轨迹(第 28 章 7 要素,US-20 可查不可篡改)。append-only:无 update/delete 对外接口,DB 层 append_only 标记,要素只能追加;已完成(done/deny)轨迹再追加 → PBL_E_APPEND_ONLY。 [APPEND-ONLY: 禁止 UPDATE/DELETE]';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_agent_trace_stage` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID',
|
||||
`trace_no` VARCHAR(128) NOT NULL COMMENT '轨迹编号',
|
||||
`stage` VARCHAR(64) NOT NULL COMMENT '要素名(7 要素之一)',
|
||||
`payload` LONGTEXT COMMENT '要素内容',
|
||||
`seq_no` INT COMMENT '追加序号(同轨迹内自增)',
|
||||
`created_at` DATETIME COMMENT '追加时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_stage_trace` (`tenant_id`, `trace_no`, `seq_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='轨迹要素追加流水:每次 append_trace 落一行,只增不改不删,用于举证 7 要素逐条可查与追加顺序(US-20)。 [APPEND-ONLY: 禁止 UPDATE/DELETE]';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_agent_approval` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制打头)',
|
||||
`approval_no` VARCHAR(128) NOT NULL COMMENT '审批单号(业务主键)',
|
||||
`trace_id` INT COMMENT '关联轨迹ID',
|
||||
`trace_no` VARCHAR(128) COMMENT '关联轨迹编号',
|
||||
`agent_code` VARCHAR(64) COMMENT '提案 Agent',
|
||||
`action_type` VARCHAR(64) NOT NULL COMMENT '审批类别(四类之一)',
|
||||
`tool_code` VARCHAR(128) COMMENT '触发审批的工具编码',
|
||||
`object_type` VARCHAR(64) COMMENT '对象类型:blueprint / tool / compile_task',
|
||||
`object_id` VARCHAR(128) COMMENT '对象ID',
|
||||
`action_payload` LONGTEXT COMMENT '提案内容快照(审批人据此判断)',
|
||||
`status` VARCHAR(64) NOT NULL DEFAULT 'pending' COMMENT '状态:pending / approved / rejected / expired',
|
||||
`requester_type` VARCHAR(64) COMMENT '提案方类型:agent / user',
|
||||
`requester_id` VARCHAR(64) COMMENT '提案方ID',
|
||||
`approver_id` VARCHAR(64) COMMENT '指定审批人(可空=任一授权人)',
|
||||
`approver_type` VARCHAR(64) COMMENT '实际审批人类型(必须 user)',
|
||||
`decided_by` VARCHAR(64) COMMENT '实际审批人ID',
|
||||
`decided_at` DATETIME COMMENT '审批时间',
|
||||
`comment` TEXT COMMENT '审批意见',
|
||||
`expires_at` DATETIME COMMENT '过期时间(过期后 pending 视为无效)',
|
||||
`created_at` DATETIME COMMENT '创建时间',
|
||||
`updated_at` DATETIME COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_approval_no` (`tenant_id`, `approval_no`),
|
||||
KEY `idx_approval_pending` (`tenant_id`, `status`, `approver_id`),
|
||||
KEY `idx_approval_trace` (`tenant_id`, `trace_no`),
|
||||
KEY `idx_approval_action` (`tenant_id`, `action_type`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人工审批单(14.2):四类强制审批 publish / compile_execute / blueprint_approve / tool_registry_change。Agent 不可自批:decide_approval 仅 actor_type=user 可调用。';
|
||||
Loading…
x
Reference in New Issue
Block a user