367 lines
19 KiB
Python
367 lines
19 KiB
Python
#!/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/admin),Agent 无此工具(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):
|
||
"""自然语言 → 结构化 Blueprint(LLM 不可用 → 模板兜底,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/confidence(14.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
|