367 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_agent_runtime.api —— Designer/Critic 运行时 + fail-closed 工具裁决第13/14/28/31章M4a/M4b
不可绕过的三条硬线QC/PM 均要求可机械核对):
1) **Critic 零写权限**agent_type=critic 的 permission_mode 恒为 read写类工具直接 DENIED
2) **无 pbl.publish**DISABLED_TOOLS 注册契约但 enabled=0裁决第 3 步即拒(含 pbl.publish
3) **四类强制人工审批**:审批未 approved 前,相关写工具在裁决第 6 步 DENIED无旁路参数。
fail-closed 8 步裁决顺序(任一步不过 = 拒绝并留痕,绝不默认放行):
① Agent 存在且启用 → ② 工具在注册表且 enabled → ③ 工具未被禁用(G6/pbl.publish) →
④ Agent 权限模式允许该副作用级别 → ⑤ 参数过 JSON Schema 白名单 → ⑥ 需审批类已 approved →
⑦ 租户归属校验 → ⑧ 执行并写 trace/tool_call 留痕。
"""
import hashlib
import json
import time
from pbl_common.api import (PblError, actor_id, crud, flag, json_dump, now_str,
sql_exec, sql_rows, sql_scalar, tenant_id)
# 13 启用第31章裁剪
ENABLED_TOOLS = [
'pbl.get', 'pbl.create', 'pbl.update', 'pbl.validate', 'pbl.compare', 'pbl.compile',
'pbl.preview', 'kdb.search', 'kdb.get', 'runtime.get_state', 'runtime.playtest',
'analytics.query', 'template.instantiate',
]
# 9 禁用注册契约、零实现入口G6 范围纪律)
DISABLED_TOOLS = [
'pbl.publish', 'kdb.add_candidate', 'kdb.propose_pattern', 'analytics.compare',
'analytics.aggregate', 'experiment.create', 'experiment.run', 'showplace.publish',
'sim.configure',
]
# 副作用级别read 类 Critic 可用write 类仅 Designer且需审批者另计
SIDE_EFFECT = {'pbl.get': 'read', 'kdb.search': 'read', 'kdb.get': 'read',
'runtime.get_state': 'read', 'analytics.query': 'read', 'pbl.preview': 'read',
'pbl.compare': 'read',
'pbl.create': 'write', 'pbl.update': 'write', 'pbl.validate': 'write',
'pbl.compile': 'write', 'runtime.playtest': 'write',
'template.instantiate': 'write'}
# 14.2 四类强制人工审批
APPROVAL_TYPES = ['publish', 'modify_learning_goal', 'modify_assessment_criteria',
'material_change_student_visible']
WRITE_TOOLS_NEEDING_APPROVAL = {'pbl.update': ['modify_learning_goal',
'modify_assessment_criteria',
'material_change_student_visible']}
LLM_TIMEOUT_S, LLM_RETRY, LLM_RATE_PER_MIN = 60, 1, 20
BUDGET_NL_S = 180
def _loads(v, d=None):
if isinstance(v, (dict, list)):
return v
try:
return json.loads(v) if v else (d or {})
except ValueError:
return d or {}
async def _tid():
return await tenant_id()
async def _agent(tid, code):
rows = await sql_rows('SELECT * FROM `pbl_agent_def` WHERE `tenant_id` = ${t}$'
' AND `code` = ${c}$ LIMIT 1', {'t': tid, 'c': code}, 'pbl')
return rows[0] if rows else None
async def _tool(tid, key):
rows = await sql_rows('SELECT * FROM `pbl_tool_registry` WHERE `tenant_id` = ${t}$'
' AND `tool_key` = ${k}$ LIMIT 1', {'t': tid, 'k': key}, 'pbl')
return rows[0] if rows else None
async def _trace_write(payload):
"""7 要素轨迹落库第28章供 pbl_agent_trace_list 审计回放。"""
sc = crud('pbl_agent_trace', 'pbl',
['trace_uid', 'agent_code', 'blueprint_id', 'input_ref_json', 'thought_txt',
'action_code', 'action_params_json', 'observation_json', 'result_state',
'llm_calls', 'duration_ms', 'created_by'])
return await sc['create'](**payload)
async def pbl_tool_registry_list(**kw):
"""工具清单13 启用 / 9 禁用(含 pbl.publish零实现入口可 grep 核对。"""
tid = await _tid()
rows = await sql_rows('SELECT * FROM `pbl_tool_registry` WHERE `tenant_id` = ${t}$'
' ORDER BY `tool_key`', {'t': tid}, 'pbl')
return {'ok': True, 'data': rows, 'enabled_keys': ENABLED_TOOLS,
'disabled_keys': DISABLED_TOOLS, 'counts': {'enabled': len(ENABLED_TOOLS),
'disabled': len(DISABLED_TOOLS)}}
async def pbl_tool_registry_save(**kw):
key = kw.get('tool_key')
if not key:
raise PblError('PBL_PARAM_MISSING', '缺少 tool_key')
sc = crud('pbl_tool_registry', 'pbl', ['tool_key', 'title', 'agent_scope',
'permission_required', 'enabled',
'params_schema_json', 'disabled_reason',
'version_no'])
payload = dict(kw)
payload['params_schema_json'] = json_dump(kw.get('params_schema_json'))
payload.setdefault('version_no', 1)
payload.setdefault('enabled', 1 if key in ENABLED_TOOLS else 0)
if payload['enabled'] and key in DISABLED_TOOLS:
raise PblError('PBL_TOOL_FORBIDDEN', '%s 属 G6 禁用清单,不得启用' % key)
return await sc['upsert'](['tool_key'], **payload)
async def pbl_tool_adjudicate(**kw):
"""fail-closed 8 步裁决(可单测:每步给独立错误码)。"""
tid = await _tid()
code, tool_key = kw.get('agent_code'), kw.get('tool_key')
params = _loads(kw.get('params_json'), {})
steps = []
agent = await _agent(tid, code)
if not agent or not flag(agent.get('is_enabled')):
return _deny(steps, 'AD01_AGENT_UNAVAILABLE', 'Agent 不存在或未启用', code, tool_key)
steps.append('AD01 ok')
tool = await _tool(tid, tool_key)
if not tool:
return _deny(steps, 'AD02_TOOL_UNREGISTERED', '工具未注册', code, tool_key)
steps.append('AD02 ok')
if tool_key in DISABLED_TOOLS or not flag(tool.get('enabled')):
return _deny(steps, 'AD03_TOOL_DISABLED',
'工具禁用G6 范围纪律):%s' % (tool.get('disabled_reason') or 'contract_only'),
code, tool_key)
steps.append('AD03 ok')
level = SIDE_EFFECT.get(tool_key, 'write')
if level == 'write' and (agent.get('permission_mode') or 'read') != 'write':
return _deny(steps, 'AD04_PERMISSION_MODE_DENIED',
'Agent permission_mode=%s,拒绝写副作用' % agent.get('permission_mode'),
code, tool_key)
steps.append('AD04 ok')
bad = _schema_check(_loads(tool.get('params_schema_json'), {}), params)
if bad:
return _deny(steps, 'AD05_PARAM_NOT_WHITELISTED', '参数越白名单:%s' % ','.join(bad),
code, tool_key)
steps.append('AD05 ok')
need = WRITE_TOOLS_NEEDING_APPROVAL.get(tool_key, [])
if need:
ok_ap, ap = await _approval_ok(tid, need, params)
if not ok_ap:
return _deny(steps, 'AD06_APPROVAL_REQUIRED',
'需人工审批14.2%s)且无 approved 单' % '/'.join(need),
code, tool_key, approval=ap)
steps.append('AD06 ok(approved)')
else:
steps.append('AD06 n/a')
if params.get('tenant_id') and params['tenant_id'] != tid:
return _deny(steps, 'AD07_TENANT_MISMATCH', '跨租户调用被拒', code, tool_key)
steps.append('AD07 ok')
return {'ok': True, 'allowed': True, 'steps': steps, 'agent_code': code,
'tool_key': tool_key, 'side_effect': level,
'agent_permission_mode': agent.get('permission_mode')}
def _deny(steps, code, msg, agent_code, tool_key, approval=None):
out = {'ok': False, 'allowed': False, 'error_code': code, 'error_msg': msg,
'steps': steps, 'agent_code': agent_code, 'tool_key': tool_key, 'fail_closed': True}
if approval:
out['approval'] = approval
return out
def _schema_check(schema, params):
"""服务端白名单Prompt 注入防御第 3 条):无 schema = 只允许空参(最严)。"""
allowed = set((schema or {}).get('properties') or schema.get('fields') or []) \
if isinstance(schema, dict) else set()
if not allowed:
return sorted(params) if params else []
return sorted(set(params) - allowed)
async def _approval_ok(tid, need, params):
rows = await sql_rows('SELECT * FROM `pbl_approval` WHERE `tenant_id` = ${t}$'
' AND `approval_type` IN (%s) AND `status` = %s'
' AND `target_id` = ${tgt}$ ORDER BY `id` DESC LIMIT 1'
% (', '.join(['${n%d}$' % i for i in range(len(need))]), "'approved'"),
dict({'t': tid, 'tgt': params.get('blueprint_id') or params.get('id') or 0},
**{'n%d' % i: n for i, n in enumerate(need)}), 'pbl')
return (bool(rows), rows[0] if rows else None)
async def pbl_agent_trace_write(**kw):
tid = await _tid()
payload = dict(kw)
payload.setdefault('trace_uid', 'T%s%s' % (now_str().replace('-', '').replace(':', '')
.replace(' ', ''), (kw.get('agent_code') or 'a')[:2]))
for j in ('input_ref_json', 'action_params_json', 'observation_json'):
payload[j] = json_dump(payload.get(j))
payload['created_by'] = await actor_id()
r = await _trace_write(payload)
r['trace_uid'] = payload['trace_uid']
return r
async def pbl_agent_trace_list(**kw):
return await crud('pbl_agent_trace', 'pbl',
['trace_uid', 'agent_code', 'blueprint_id', 'action_code', 'result_state',
'llm_calls', 'duration_ms', 'created_by'])['list'](**kw)
async def pbl_approval_create(**kw):
atype = kw.get('approval_type')
if atype not in APPROVAL_TYPES:
raise PblError('PBL_APPROVAL_TYPE_UNKNOWN', 'approval_type 须为四类之一:%s'
% '/'.join(APPROVAL_TYPES))
sc = crud('pbl_approval', 'pbl', ['approval_uid', 'approval_type', 'target_type',
'target_id', 'payload_json', 'status', 'requested_by',
'decided_by', 'decided_at', 'comment_txt'])
payload = dict(kw)
payload['payload_json'] = json_dump(kw.get('payload_json'))
payload.setdefault('approval_uid', 'A%s' % hashlib.sha256(
(atype + str(kw.get('target_id')) + now_str()).encode()).hexdigest()[:20])
payload.setdefault('status', 'pending')
payload['requested_by'] = await actor_id()
r = await sc['create'](**payload)
r['approval_uid'] = payload['approval_uid']
r['note'] = 'pending 期间相关写工具在裁决第 6 步 DENIED无绕过参数'
return r
async def pbl_approval_decide(**kw):
"""决策仅人类角色可调用rbac 路径 teacher/adminAgent 无此工具DISABLED_TOOLS 无例外)。"""
tid = await _tid()
status = kw.get('status')
if status not in ('approved', 'rejected', 'withdrawn'):
raise PblError('PBL_APPROVAL_STATUS_INVALID', 'status 非法')
rows = await sql_rows('SELECT * FROM `pbl_approval` WHERE `tenant_id` = ${t}$'
' AND (`id` = ${id}$ OR `approval_uid` = ${id2}$) LIMIT 1',
{'t': tid, 'id': kw.get('id') or 0, 'id2': kw.get('approval_uid') or ''},
'pbl')
if not rows:
raise PblError('PBL_NOT_FOUND', '审批单不存在')
await sql_exec('UPDATE `pbl_approval` SET `status` = ${s}$,`decided_by` = ${u}$,'
' `decided_at` = ${ts}$,`comment_txt` = ${c}$,`updated_at` = ${ts2}$'
' WHERE `id` = ${id}$ AND `tenant_id` = ${t}$',
{'s': status, 'u': await actor_id(), 'ts': now_str(),
'c': kw.get('comment_txt'), 'ts2': now_str(), 'id': rows[0]['id'],
't': tid}, 'pbl')
return {'ok': True, 'id': rows[0]['id'], 'status': status}
async def pbl_approval_list(**kw):
return await crud('pbl_approval', 'pbl',
['approval_uid', 'approval_type', 'target_type', 'target_id', 'status',
'requested_by', 'decided_by', 'decided_at', 'comment_txt'])['list'](**kw)
# ── LLM 路由pipeline-llm超时/重试/限流;不可达立即兜底,闭环不中断)─────────
async def _llm(route, prompt, schema_hint):
from ahserver.serverenv import ServerEnv
env = ServerEnv()
fn = getattr(env, 'pbl_llm_invoke', None) or getattr(env, 'llm_invoke', None)
t0 = time.time()
calls = 0
for attempt in range(LLM_RETRY + 1):
calls += 1
if not callable(fn):
return None, 'no_llm_route', calls, int((time.time() - t0) * 1000)
try:
out = fn(route=route, prompt=prompt, schema=schema_hint, timeout=LLM_TIMEOUT_S)
if hasattr(out, '__await__'):
out = await out
if out:
return out, None, calls, int((time.time() - t0) * 1000)
except Exception as exc:
last = str(exc)[:200]
return None, 'llm_unavailable', calls, int((time.time() - t0) * 1000)
async def pbl_agent_designer_run(**kw):
"""自然语言 → 结构化 BlueprintLLM 不可用 → 模板兜底generation_source=template_fallback"""
tid = await _tid()
nl = kw.get('input_txt') or kw.get('prompt') or ''
agent = await _agent(tid, kw.get('agent_code') or 'designer')
if not agent:
raise PblError('AD01_AGENT_UNAVAILABLE', 'designer Agent 未登记')
if (agent.get('permission_mode') or 'read') != 'write':
raise PblError('AD04_PERMISSION_MODE_DENIED', 'designer 须 write 模式')
# Prompt 注入防御第 1 条:用户输入只作数据,不拼进指令位
prompt = json_dump({'task': 'generate_pbl_blueprint', 'user_input_as_data': nl,
'subject': kw.get('subject'), 'grade': kw.get('grade')})
raw, err, calls, ms = await _llm(agent.get('model_route') or 'pipeline-llm', prompt,
'pbl.blueprint.v1')
blueprint = _loads(raw, {}) if raw else {}
fallback = not blueprint
if fallback:
from pbl_blueprint.api import pbl_template_instantiate
tpl = kw.get('template_code') or 'TPL-PBL-DEFAULT'
r = await pbl_template_instantiate(code=tpl, title=(nl[:40] or '未命名 PBL'))
bp_id = r['blueprint_id']
else:
from pbl_blueprint.api import pbl_blueprint_create
r = await pbl_blueprint_create(title=(nl[:40] or '未命名 PBL'),
subject=kw.get('subject'), grade=kw.get('grade'),
content_json=blueprint, generation_source='llm')
bp_id = r['id']
await _trace_write({
'trace_uid': 'T%s' % hashlib.sha256((nl + now_str()).encode()).hexdigest()[:20],
'agent_code': agent['code'], 'blueprint_id': bp_id,
'input_ref_json': json_dump({'nl_len': len(nl), 'hash': _loads(prompt).get('x') or ''}),
'thought_txt': '生成结构化蓝图(%s' % ('兜底模板' if fallback else 'LLM'),
'action_code': 'pbl.create', 'action_params_json': json_dump({'subject': kw.get('subject')}),
'observation_json': json_dump({'blueprint_id': bp_id, 'llm_error': err}),
'result_state': 'fallback' if fallback else 'generated',
'llm_calls': calls, 'duration_ms': ms, 'created_by': await actor_id()})
await sql_exec('INSERT INTO `pbl_llm_call_log` (`tenant_id`,`call_uid`,`agent_code`,`route`,'
'`model`,`prompt_hash`,`completion_tokens`,`latency_ms`,`status`,'
'`error_code`,`fallback_used`,`created_at`) VALUES '
'(${t}$,${c}$,${a}$,${r}$,${m}$,${p}$,${n}$,${l}$,${s}$,${e}$,${f}$,${ts}$)',
{'t': tid, 'c': 'L%s' % now_str().replace('-', '').replace(':', '')
.replace(' ', ''), 'a': agent['code'],
'r': agent.get('model_route'), 'm': '', 'p': hashlib.sha256(
prompt.encode()).hexdigest()[:16], 'n': 0, 'l': ms,
's': 'ok' if not err else 'error', 'e': err, 'f': 1 if fallback else 0,
'ts': now_str()}, 'pbl')
return {'ok': True, 'blueprint_id': bp_id, 'generation_source': 'template_fallback'
if fallback else 'llm', 'llm_error': err, 'llm_calls': calls,
'latency_ms': ms, 'budget_ms': BUDGET_NL_S * 1000,
'within_budget': ms <= BUDGET_NL_S * 1000}
async def pbl_agent_critic_run(**kw):
"""Critic只读评审输出 recommendation/reason/evidence/confidence14.3 可解释)。"""
tid = await _tid()
agent = await _agent(tid, kw.get('agent_code') or 'critic')
if not agent:
raise PblError('AD01_AGENT_UNAVAILABLE', 'critic Agent 未登记')
if (agent.get('permission_mode') or 'read') != 'read':
raise PblError('AD04_PERMISSION_MODE_DENIED',
'Critic 必须 read-only零写权限当前 permission_mode=%s'
% agent.get('permission_mode'))
bp_id = kw.get('blueprint_id')
val = await sql_rows('SELECT `quality_state`,`score`,`failed_count`,`findings_json`'
' FROM `pbl_validation_result` WHERE `tenant_id` = ${t}$'
' AND `blueprint_id` = ${b}$ ORDER BY `id` DESC LIMIT 1',
{'t': tid, 'b': bp_id}, 'pbl')
findings = _loads(val[0]['findings_json'], []) if val else []
weak = [f for f in findings if not f.get('passed')]
rec = 'revise' if weak else 'proceed'
out = {'ok': True, 'agent_code': agent['code'], 'writable': False,
'recommendation': rec,
'reason': '未通过维度 %d 个(%s' % (len(weak), ','.join(
f['dimension'] for f in weak[:5])) if weak else '14 维全部通过',
'evidence': {'validation_id_found': bool(val),
'quality_state': val[0]['quality_state'] if val else None,
'score': val[0]['score'] if val else None,
'weak_dimensions': [f['dimension'] for f in weak]},
'confidence': round(max(0.0, 1.0 - 0.12 * len(weak)), 2),
'suggestions': [f.get('recommendation') for f in weak][:5]}
await _trace_write({
'trace_uid': 'TC%s' % hashlib.sha256(('%s%s' % (bp_id, now_str())).encode())
.hexdigest()[:18],
'agent_code': agent['code'], 'blueprint_id': bp_id,
'input_ref_json': json_dump({'validation_found': bool(val)}),
'thought_txt': out['reason'], 'action_code': 'pbl.get',
'action_params_json': json_dump({'blueprint_id': bp_id}),
'observation_json': json_dump(out['evidence']),
'result_state': rec, 'llm_calls': 0, 'duration_ms': 0,
'created_by': await actor_id()})
return out