655 lines
36 KiB
Python
655 lines
36 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""pbls 离线契约冒烟测试(无 DB / 无网络,用内存 FakeDB 驱动真实 api.py 代码路径)。
|
||
|
||
覆盖硬门禁与关键红线:
|
||
A 租户强制 + CRUD 白名单丢弃越权字段
|
||
B 14 维校验 + 5 级质量状态阶梯(含 blocking 不放行)
|
||
C 确定性编译(draft 门禁拒 → 同输入两次指纹一致 → 改版本指纹变 → 幂等命中)
|
||
D 证据幂等(同 (source_event_id, evidence_type) 二次采集 deduped,不产生第二行)
|
||
E fail-closed 8 步裁决:Critic 写 / 禁用工具(pbl.publish) / 未注册 / 参数白名单 / 审批缺失
|
||
F Rubric 权重合计≠100% 拒绝;=100% 通过且 total_score 派生
|
||
G KDB 零写入 + 匿名聚合 k=5 抑制 + 个体维度拒绝
|
||
H 运行时事件 seq/state_version 单调 + 乐观锁 + 8 人上限 + 时延预算实测
|
||
I appcodes 幂等注入(二次执行 inserted=0)
|
||
J 入口挂载静态核验(ServerEnv 先于 load_rbac + 契约三处同步 + 12 loader 可导入)
|
||
K G6 十一条禁止实现零命中
|
||
L 前端覆盖层 12 页面 + 渲染时延预算(可执行验证方式)
|
||
"""
|
||
import asyncio
|
||
import os
|
||
import re
|
||
import sys
|
||
import types
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.abspath(os.path.join(HERE, *(['..'] * 4)))
|
||
|
||
for _m in sorted(os.listdir(os.path.join(ROOT, 'modules'))):
|
||
if _m.startswith('pbl_'):
|
||
sys.path.insert(0, os.path.join(ROOT, 'modules', _m))
|
||
sys.path.insert(0, os.path.join(ROOT, 'apps', 'pbls', 'app'))
|
||
|
||
|
||
# ── FakeDB:支持 INSERT/UPDATE/DELETE/SELECT(等值+字面量+LIKE+>)/COUNT/MAX/GROUP BY ──
|
||
class FakeSor:
|
||
def __init__(self, db):
|
||
self.db = db
|
||
|
||
async def sqlExe(self, sql, params=None):
|
||
p = dict(params or {})
|
||
s = ' '.join(sql.split())
|
||
self.db.log.append(s[:160])
|
||
up = s.upper()
|
||
if up.startswith('INSERT'):
|
||
return self._insert(s, p)
|
||
if up.startswith('UPDATE'):
|
||
return self._update(s, p)
|
||
if up.startswith('DELETE'):
|
||
return self._delete(s, p)
|
||
return self._select(s, p, up)
|
||
|
||
# ---- INSERT ----
|
||
def _insert(self, s, p):
|
||
tbl = re.search(r'INSERT INTO `(\w+)`', s).group(1)
|
||
colpart = s[s.index('(') + 1:s.index(')')]
|
||
cols = re.findall(r'`(\w+)`', colpart)
|
||
vals = s[s.upper().index('VALUES') + 6:]
|
||
ph = re.findall(r'\$\{(\w+)\}\$', vals)
|
||
self.db.next_id += 1
|
||
row = {}
|
||
for i, c in enumerate(cols):
|
||
row[c] = p.get(ph[i]) if i < len(ph) else None
|
||
row['id'] = self.db.next_id
|
||
self.db.rows.setdefault(tbl, []).append(row)
|
||
return [{'id': self.db.next_id}]
|
||
|
||
# ---- UPDATE ----
|
||
def _update(self, s, p):
|
||
tbl = re.search(r'UPDATE `(\w+)`', s).group(1)
|
||
where = s[s.upper().index(' WHERE ') + 7:] if ' WHERE ' in s.upper() else ''
|
||
setpart = s[s.upper().index(' SET ') + 5: s.upper().index(' WHERE ')] if ' WHERE ' in s.upper() \
|
||
else s[s.upper().index(' SET ') + 5:]
|
||
sets = re.findall(r'`(\w+)`\s*=\s*\$\{(\w+)\}\$', setpart)
|
||
n = 0
|
||
for row in self.db.rows.get(tbl, []):
|
||
if not self._match(row, where, p):
|
||
continue
|
||
for col, key in sets:
|
||
row[col] = p.get(key)
|
||
if '`use_count` = `use_count` + 1' in setpart:
|
||
row['use_count'] = int(row.get('use_count') or 0) + 1
|
||
n += 1
|
||
return [{'affected': n}]
|
||
|
||
# ---- DELETE ----
|
||
def _delete(self, s, p):
|
||
tbl = re.search(r'DELETE FROM `(\w+)`', s).group(1)
|
||
where = s[s.upper().index(' WHERE ') + 7:] if ' WHERE ' in s.upper() else ''
|
||
keep, n = [], 0
|
||
for row in self.db.rows.get(tbl, []):
|
||
if self._match(row, where, p):
|
||
n += 1
|
||
else:
|
||
keep.append(row)
|
||
self.db.rows[tbl] = keep
|
||
return [{'affected': n}]
|
||
|
||
# ---- SELECT ----
|
||
def _select(self, s, p, up):
|
||
if 'LAST_INSERT_ID' in up:
|
||
return [{'pk': self.db.next_id}]
|
||
m = re.search(r'FROM `(\w+)`', s)
|
||
tbl = m.group(1) if m else '?'
|
||
rows = self.db.rows.get(tbl, [])
|
||
where = ''
|
||
if ' WHERE ' in up:
|
||
w = s[s.upper().index(' WHERE ') + 7:]
|
||
where = re.split(r' ORDER BY | GROUP BY | LIMIT ', w)[0]
|
||
matched = [r for r in rows if self._match(r, where, p)]
|
||
gm = re.search(r'GROUP BY `(\w+)`', s)
|
||
if gm:
|
||
col = gm.group(1)
|
||
groups = {}
|
||
for r in matched:
|
||
groups.setdefault(r.get(col), []).append(r)
|
||
out = []
|
||
for g, items in sorted(groups.items(), key=lambda x: str(x[0])):
|
||
scores = [float(i.get('total_score') or 0) for i in items]
|
||
out.append({'g': g, 'n': len(items),
|
||
'avg_score': sum(scores) / len(scores) if scores else 0,
|
||
'min_score': min(scores) if scores else 0,
|
||
'max_score': max(scores) if scores else 0})
|
||
return out
|
||
if 'COUNT(*)' in up:
|
||
return [{'c': len(matched)}]
|
||
m = re.search(r'COALESCE\((MAX|COUNT)\(`(\w+)`\),0\) AS (\w+)', s)
|
||
if m:
|
||
fn, col, alias = m.groups()
|
||
vals = [r.get(col) for r in matched if r.get(col) is not None]
|
||
if fn == 'COUNT':
|
||
return [{alias: len(matched)}]
|
||
try:
|
||
return [{alias: max(vals) if vals else 0}]
|
||
except TypeError:
|
||
return [{alias: 0}]
|
||
m = re.search(r'MAX\(`(\w+)`\) AS (\w+)', s)
|
||
if m:
|
||
col, alias = m.groups()
|
||
vals = [r.get(col) for r in matched if r.get(col) is not None]
|
||
try:
|
||
return [{alias: max(vals) if vals else 0}]
|
||
except TypeError:
|
||
return [{alias: 0}]
|
||
if 'DISTINCT' in up:
|
||
col = re.search(r'DISTINCT `(\w+)`', s).group(1)
|
||
seen = []
|
||
for r in matched:
|
||
if r.get(col) is not None and r[col] not in seen:
|
||
seen.append(r[col])
|
||
return [{col: v} for v in seen]
|
||
lim = re.search(r'LIMIT (\d+)', s)
|
||
if lim:
|
||
matched = matched[:int(lim.group(1))]
|
||
return [dict(r) for r in matched]
|
||
|
||
def _match(self, row, where, p):
|
||
if not where:
|
||
return True
|
||
for cond in re.split(r' AND ', where):
|
||
cond = cond.strip().strip('()')
|
||
m = re.match(r'`(\w+)`\s*=\s*\$\{(\w+)\}\$$?', cond)
|
||
if m:
|
||
col, key = m.groups()
|
||
want = p.get(key)
|
||
if want in (None, ''):
|
||
continue
|
||
if str(row.get(col)) != str(want):
|
||
return False
|
||
continue
|
||
m = re.match(r'`(\w+)`\s*=\s*\'([^\']*)\'$', cond)
|
||
if m:
|
||
col, want = m.groups()
|
||
if str(row.get(col)) != want:
|
||
return False
|
||
continue
|
||
m = re.match(r'`(\w+)`\s*>\s*\$\{(\w+)\}\$$?', cond)
|
||
if m:
|
||
col, key = m.groups()
|
||
try:
|
||
if float(row.get(col) or 0) <= float(p.get(key) or 0):
|
||
return False
|
||
except (TypeError, ValueError):
|
||
return False
|
||
continue
|
||
m = re.match(r'`(\w+)` IN \(([^)]*)\)$', cond)
|
||
if m:
|
||
col, keys = m.group(1), re.findall(r'\$\{(\w+)\}', m.group(2))
|
||
vals = [p.get(k) for k in keys if p.get(k) not in (None, '')]
|
||
if vals and str(row.get(col)) not in [str(v) for v in vals]:
|
||
return False
|
||
continue
|
||
if 'LIKE' in cond.upper():
|
||
m = re.match(r'`(\w+)` LIKE \$\{(\w+)\}\$$?', cond)
|
||
if m:
|
||
col, key = m.groups()
|
||
pat = str(p.get(key) or '').replace('%', '')
|
||
if pat and pat not in str(row.get(col) or ''):
|
||
return False
|
||
continue
|
||
return True
|
||
|
||
|
||
class FakeCtx:
|
||
def __init__(self, db, name):
|
||
self.db, self.name = db, name
|
||
|
||
async def __aenter__(self):
|
||
return FakeSor(self.db)
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
|
||
class FakeDBMod:
|
||
def __init__(self):
|
||
self.rows, self.log, self.next_id = {}, [], 0
|
||
|
||
def sqlorContext(self, name):
|
||
return FakeCtx(self, name)
|
||
|
||
|
||
fdb = FakeDBMod()
|
||
_ap = types.ModuleType('apppublic')
|
||
_ap.db = fdb
|
||
sys.modules['apppublic'] = _ap
|
||
|
||
ah = types.ModuleType('ahserver')
|
||
se = types.ModuleType('ahserver.serverenv')
|
||
|
||
|
||
class ServerEnv:
|
||
_inst = None
|
||
|
||
def __new__(cls):
|
||
if cls._inst is None:
|
||
cls._inst = object.__new__(cls)
|
||
return cls._inst
|
||
|
||
def __init__(self):
|
||
self.pbl_default_tenant = 'T-DEMO'
|
||
|
||
|
||
se.ServerEnv = ServerEnv
|
||
ah.serverenv = se
|
||
sys.modules['ahserver'] = ah
|
||
sys.modules['ahserver.serverenv'] = se
|
||
_ge = types.ModuleType('ahserver.globalEnv')
|
||
_ge.password_encode = lambda s: s
|
||
sys.modules['ahserver.globalEnv'] = _ge
|
||
_wa = types.ModuleType('ahserver.webapp')
|
||
_wa.webapp = lambda init: None
|
||
sys.modules['ahserver.webapp'] = _wa
|
||
|
||
RESULTS = []
|
||
|
||
|
||
def check(name, cond, detail=''):
|
||
RESULTS.append((name, bool(cond), detail))
|
||
print('%-4s %s %s' % ('PASS' if cond else 'FAIL', name, detail))
|
||
|
||
|
||
async def main():
|
||
from pbl_common import api as C
|
||
from pbl_blueprint import api as BP
|
||
from pbl_validation import api as VA
|
||
from pbl_compiler import api as CO
|
||
from pbl_agent_runtime import api as AR
|
||
from pbl_evidence import api as EV
|
||
from pbl_assessment import api as AS
|
||
from pbl_kdb_ext import api as KD
|
||
from pbl_runtime_ext import api as RT
|
||
from pbl_appcodes import api as AC
|
||
from pbl_scense_ext import api as SX
|
||
from pbl_domain_ext import api as DX
|
||
|
||
# ── A 租户 + CRUD 白名单 ────────────────────────────────────────────
|
||
r = await BP.pbl_blueprint_create(title='湿地调查', subject='science', grade='g5',
|
||
evil_column='x')
|
||
check('A1 蓝图创建返回 id', r.get('ok') and r.get('id'), str(r.get('id')))
|
||
check('A2 越权字段被丢弃不入库', r.get('dropped_fields') == ['evil_column'],
|
||
str(r.get('dropped_fields')))
|
||
check('A3 租户上下文注入', r.get('tenant_id') == 'T-DEMO', str(r.get('tenant_id')))
|
||
rd = await BP.pbl_blueprint_read(id=r['id'])
|
||
check('A4 聚合读含 7 类子对象槽', set(rd['subobjects']) == {
|
||
'learning_goal', 'role', 'mission', 'task', 'artifact_spec', 'evidence_spec',
|
||
'reflection_spec'}, str(sorted(rd['subobjects'])))
|
||
|
||
# ── B 14 维校验 + 5 级质量状态 ─────────────────────────────────────
|
||
for i, (code, title) in enumerate([('LG1', '理解生态链'), ('LG2', '数据建模'),
|
||
('LG3', '公众表达')]):
|
||
await BP.pbl_blueprint_subobject_save(kind='learning_goal', blueprint_id=r['id'],
|
||
code=code, title=title, bloom_level='analyze',
|
||
measurable=1, order_no=i + 1)
|
||
await BP.pbl_blueprint_update(id=r['id'], content_json={
|
||
'schema': 'pbl.blueprint.v1', 'driving_question': '我们如何拯救社区湿地?',
|
||
'scenario': '本地湿地富营养化,社区需要一份可执行方案',
|
||
'learning_goals': [{'uid': 'LG1', 'code': 'LG1'}, {'uid': 'LG2', 'code': 'LG2'},
|
||
{'uid': 'LG3', 'code': 'LG3'}],
|
||
'rubric_criteria': [{'code': 'C1', 'learning_goal_id': 'LG1', 'weight': 40},
|
||
{'code': 'C2', 'learning_goal_id': 'LG2', 'weight': 35},
|
||
{'code': 'C3', 'learning_goal_id': 'LG3', 'weight': 25}]})
|
||
await BP.pbl_blueprint_subobject_save(kind='mission', blueprint_id=r['id'], code='MS1',
|
||
title='拯救湿地', scenario_txt='富营养化现场',
|
||
order_no=1)
|
||
await BP.pbl_blueprint_subobject_save(kind='role', blueprint_id=r['id'], code='R1',
|
||
title='生态调研员', headcount=3, order_no=1)
|
||
await BP.pbl_blueprint_subobject_save(kind='role', blueprint_id=r['id'], code='R2',
|
||
title='数据分析师', headcount=3, order_no=2)
|
||
await BP.pbl_blueprint_subobject_save(kind='artifact_spec', blueprint_id=r['id'], code='A1',
|
||
title='治理方案', artifact_type='document', order_no=1)
|
||
await BP.pbl_blueprint_subobject_save(kind='evidence_spec', blueprint_id=r['id'],
|
||
code='E1', evidence_type='artifact', min_count=2,
|
||
rubric_criterion_uid='C1', order_no=1)
|
||
await BP.pbl_blueprint_subobject_save(kind='evidence_spec', blueprint_id=r['id'],
|
||
code='E2', evidence_type='reflection', min_count=1,
|
||
rubric_criterion_uid='C2', order_no=2)
|
||
await BP.pbl_blueprint_subobject_save(kind='reflection_spec', blueprint_id=r['id'],
|
||
code='RF1', trigger='mission.completed',
|
||
prompt_txt='你学到了什么?', min_words=50,
|
||
evidence_type='reflection', order_no=1)
|
||
v = await VA.pbl_validation_run(blueprint_id=r['id'], apply_state=1)
|
||
check('B1 校验 14 维全覆盖', v['dimension_count'] == 14, str(v['dimension_count']))
|
||
check('B2 逐维可解释结论齐备', len(v['findings']) == 14 and all(
|
||
'detail' in f for f in v['findings']), str(len(v['findings'])))
|
||
check('B3 质量状态在 5 级阶梯内', v['quality_state'] in
|
||
['draft', 'needs_work', 'pbl_ready', 'playtest_ready', 'publish_ready'],
|
||
'%s score=%s' % (v['quality_state'], v['score']))
|
||
check('B4 校验时延预算 ≤2000ms 实测', v['within_budget'], '%sms' % v['latency_ms'])
|
||
check('B5 blocking 未清零不得跳级', not (v['blocking'] > 0 and
|
||
v['quality_state'] == 'publish_ready'),
|
||
'blocking=%s' % v['blocking'])
|
||
vfull = await VA.pbl_validation_run(blueprint_id=r['id'])
|
||
check('B6 重复校验产生新记录(历史不覆盖)', vfull['ok'], 'idempotency=n/a')
|
||
|
||
# ── C 确定性编译 ────────────────────────────────────────────────────
|
||
await CO.pbl_capability_register(capability_key='pbl.mission.show', category='mission',
|
||
is_enabled=1)
|
||
await CO.pbl_capability_register(capability_key='pbl.artifact.submit', category='artifact',
|
||
is_enabled=1)
|
||
try:
|
||
await CO.pbl_compiler_compile(blueprint_id=r['id'])
|
||
check('C0 draft/未达门禁编译被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('C0 质量门禁拒编译', e.code == 'PBL_COMPILE_GATE_FAILED', e.code)
|
||
await BP.pbl_blueprint_version_create(blueprint_id=r['id'])
|
||
c1 = await CO.pbl_compiler_compile(blueprint_id=r['id'], min_quality_state='draft')
|
||
c2 = await CO.pbl_compiler_compile(blueprint_id=r['id'], min_quality_state='draft')
|
||
check('C1 编译产出 SHA-256 指纹', len(str(c1.get('content_fingerprint'))) == 64,
|
||
str(c1.get('content_fingerprint'))[:16])
|
||
check('C2 同输入+同编译版本 → 同指纹(29.6)',
|
||
c1['content_fingerprint'] == c2['content_fingerprint'],
|
||
'%s ==' % c1['content_fingerprint'][:12])
|
||
check('C2b 同指纹命中幂等(不重复建行)', c2.get('idempotent') is True,
|
||
str(c2.get('note'))[:20])
|
||
pv = await CO.pbl_compiler_preview(blueprint_id=r['id'])
|
||
check('C3 干跑不落库且指纹一致', pv['content_fingerprint'] == c1['content_fingerprint']
|
||
and pv['preview'], 'entities=%s' % len(pv['definition']['entities']))
|
||
check('C3b 事件映射为 script_type=1 规则 JSON(不建规则引擎)',
|
||
all(b['script_type'] == 1 for b in pv['definition']['script_bindings'])
|
||
and len(pv['definition']['script_bindings']) == len(pv['definition']['events']),
|
||
'bindings=%s' % len(pv['definition']['script_bindings']))
|
||
await BP.pbl_blueprint_update(id=r['id'], content_json={'schema': 'pbl.blueprint.v1',
|
||
'missions': [{'obj_uid': 'M9'}]})
|
||
await BP.pbl_blueprint_version_create(blueprint_id=r['id'])
|
||
c3 = await CO.pbl_compiler_compile(blueprint_id=r['id'], blueprint_version_no=2,
|
||
min_quality_state='draft', force_new=1)
|
||
check('C4 改输入 → 指纹变(非缓存假阳性)',
|
||
c3['content_fingerprint'] != c1['content_fingerprint'], 'v2 fp 不同')
|
||
cmp_ = await CO.pbl_compiler_compare(a=c1['id'], b=c3['id'])
|
||
check('C5 两版定义结构化 diff 可用', cmp_['ok'] and cmp_['identical'] is False,
|
||
'added=%s removed=%s' % (len(cmp_['diff']['added']), len(cmp_['diff']['removed'])))
|
||
check('C6 编译时延预算 ≤5000ms 实测', c3['latency_ms'] <= c3['budget_ms'],
|
||
'%sms' % c3['latency_ms'])
|
||
mat = await DX.pbl_domain_materialize_game_definition(id=c3['id'])
|
||
check('C7 物化走基座契约/降级如实回报', mat['ok'] and 'degraded' in mat and
|
||
mat.get('base_tables_altered') is False, 'degraded=%s' % mat.get('degraded'))
|
||
|
||
# ── E fail-closed 8 步裁决 ──────────────────────────────────────────
|
||
await AR.pbl_tool_registry_save(tool_key='pbl.get', title='读蓝图', enabled=1,
|
||
agent_scope='both')
|
||
await AR.pbl_tool_registry_save(tool_key='pbl.update', title='改蓝图', enabled=1,
|
||
agent_scope='designer',
|
||
params_schema_json={'properties': {'blueprint_id': {},
|
||
'content_json': {}}})
|
||
await AR.pbl_tool_registry_save(tool_key='pbl.publish', title='发布', enabled=0,
|
||
agent_scope='none', disabled_reason='G6_no_publish_tool')
|
||
from pbl_common.api import sql_exec as _x
|
||
for code, mode in (('designer', 'write'), ('critic', 'read')):
|
||
await _x('INSERT INTO `pbl_agent_def` (`tenant_id`,`code`,`agent_type`,`name`,'
|
||
'`model_route`,`permission_mode`,`is_enabled`,`version_no`,`created_at`)'
|
||
' VALUES (${t}$,${c}$,${a}$,${n}$,${r}$,${m}$,${en}$,${vn}$,${ts}$)',
|
||
{'t': 'T-DEMO', 'c': code, 'a': code, 'n': code, 'r': 'pipeline-llm',
|
||
'm': mode, 'en': 1, 'vn': 1, 'ts': '2026-09-15 10:00:00'}, 'pbl')
|
||
d1 = await AR.pbl_tool_adjudicate(agent_code='critic', tool_key='pbl.update',
|
||
params_json={'blueprint_id': 1})
|
||
check('E1 Critic 写工具 DENIED(零写权限)',
|
||
d1['allowed'] is False and d1['error_code'] == 'AD04_PERMISSION_MODE_DENIED',
|
||
d1['error_code'])
|
||
d1b = await AR.pbl_tool_adjudicate(agent_code='critic', tool_key='pbl.get', params_json={})
|
||
check('E1b Critic 读工具放行', d1b['allowed'] is True, str(d1b['steps'][-1]))
|
||
d2 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.publish',
|
||
params_json={})
|
||
check('E2 pbl.publish 恒拒(G6 无绕过)',
|
||
d2['allowed'] is False and d2['error_code'] == 'AD03_TOOL_DISABLED', d2['error_code'])
|
||
d3 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='evil.tool', params_json={})
|
||
check('E3 未注册工具 DENIED', d3['error_code'] == 'AD02_TOOL_UNREGISTERED', d3['error_code'])
|
||
d4 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.update',
|
||
params_json={'evil': 1})
|
||
check('E4 参数不在服务端白名单 DENIED', d4['error_code'] == 'AD05_PARAM_NOT_WHITELISTED',
|
||
d4['error_code'])
|
||
d5 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.update',
|
||
params_json={'blueprint_id': 1})
|
||
check('E5 需人工审批且无 approved 单 → DENIED',
|
||
d5['error_code'] == 'AD06_APPROVAL_REQUIRED', d5['error_code'])
|
||
ap = await AR.pbl_approval_create(approval_type='modify_learning_goal',
|
||
target_type='pbl_blueprint', target_id=r['id'])
|
||
check('E5b 审批单创建为 pending', ap.get('ok') and ap.get('approval_uid'),
|
||
str(ap.get('approval_uid'))[:12])
|
||
dec = await AR.pbl_approval_decide(id=ap['id'], status='approved', comment_txt='同意')
|
||
check('E5c 人工决策后放行(第6步通过)', dec.get('ok') is True, dec.get('status'))
|
||
try:
|
||
await AR.pbl_approval_create(approval_type='auto_publish', target_id=1)
|
||
check('E6 非法审批类型被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('E6 非法审批类型被拒', e.code == 'PBL_APPROVAL_TYPE_UNKNOWN', e.code)
|
||
try:
|
||
await AR.pbl_tool_registry_save(tool_key='sim.configure', enabled=1)
|
||
check('E7 G6 禁用工具不得启用', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('E7 G6 禁用工具不得启用', e.code == 'PBL_TOOL_FORBIDDEN', e.code)
|
||
tl = await AR.pbl_tool_registry_list()
|
||
check('E8 13 启用 / 9 禁用清单齐备',
|
||
len(tl['enabled_keys']) == 13 and len(tl['disabled_keys']) == 9
|
||
and 'pbl.publish' in tl['disabled_keys'], str(tl['counts']))
|
||
cr = await AR.pbl_agent_critic_run(blueprint_id=r['id'])
|
||
check('E9 Critic 输出四要素且 writable=False',
|
||
cr['writable'] is False and all(k in cr for k in
|
||
('recommendation', 'reason', 'evidence',
|
||
'confidence')), 'conf=%s' % cr['confidence'])
|
||
dr = await AR.pbl_agent_designer_run(input_txt='给我做一个湿地 PBL', subject='science',
|
||
grade='g5', template_code='TPL-NOT-EXIST')
|
||
check('E10 LLM 不可达 → 兜底路径闭环不中断',
|
||
dr['ok'] and dr['generation_source'] == 'template_fallback',
|
||
'err=%s %sms' % (dr['llm_error'], dr['latency_ms']))
|
||
tr = await AR.pbl_agent_trace_list()
|
||
check('E11 轨迹留痕(7 要素)可读', tr['ok'] and tr['total'] >= 2, 'traces=%s' % tr['total'])
|
||
|
||
# ── D 证据幂等 ──────────────────────────────────────────────────────
|
||
a1 = await EV.pbl_artifact_create(title='湿地报告', session_id=1, blueprint_id=r['id'],
|
||
content_json={'a': 1})
|
||
e1 = await EV.pbl_evidence_collect(evidence_type='artifact', source_event_id='EV-1',
|
||
session_id=1, learner_id='U1', artifact_id=a1['id'])
|
||
e2 = await EV.pbl_evidence_collect(evidence_type='artifact', source_event_id='EV-1',
|
||
session_id=1, learner_id='U1', artifact_id=a1['id'])
|
||
check('D1 首次采集落库', e1.get('deduped') is False, str(e1.get('id')))
|
||
check('D2 重复采集幂等(唯一索引)', e2.get('deduped') is True and
|
||
e2.get('id') == e1.get('id'), '%s==%s' % (e1.get('id'), e2.get('id')))
|
||
check('D2b 幂等键为 (tenant,source_event_id,evidence_type)',
|
||
'uk_ev_dedup' in e1.get('unique_index', ''), e1.get('unique_index'))
|
||
try:
|
||
await EV.pbl_evidence_collect(evidence_type='artifact')
|
||
check('D3 缺 source_event_id 拒绝', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('D3 缺 source_event_id 拒绝', e.code == 'PBL_PARAM_MISSING', e.code)
|
||
try:
|
||
await EV.pbl_evidence_collect(evidence_type='ghost', source_event_id='X')
|
||
check('D3b 非法证据类型拒绝', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('D3b 非法证据类型拒绝', e.code == 'PBL_EVIDENCE_TYPE_INVALID', e.code)
|
||
au = await EV.pbl_artifact_update(id=a1['id'], content_json={'a': 2})
|
||
check('D4 产出物更新版本递增', au.get('ok'), 'v=%s' % au.get('version_no'))
|
||
el = await EV.pbl_evidence_list(session_id=1, learner_id='U1')
|
||
check('D5 证据按类型聚合可读', el['ok'] and 'by_type' in el, str(el['by_type']))
|
||
|
||
# ── F Rubric ────────────────────────────────────────────────────────
|
||
try:
|
||
await AS.pbl_rubric_save(title='湿地评分', blueprint_id=r['id'], criteria_json=[
|
||
{'code': 'C1', 'weight': 60, 'learning_goal_id': 'LG1', 'max_points': 5}])
|
||
check('F1 权重≠100% 拒绝', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('F1 权重≠100% 拒绝', e.code == 'PBL_RUBRIC_WEIGHT_INVALID', e.code)
|
||
try:
|
||
await AS.pbl_rubric_save(title='x', blueprint_id=r['id'], criteria_json=[
|
||
{'code': 'C1', 'weight': 100}])
|
||
check('F1b 准则缺 learning_goal_id 拒绝', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('F1b 准则缺 learning_goal_id 拒绝',
|
||
e.code == 'PBL_RUBRIC_ALIGNMENT_MISSING', e.code)
|
||
ru = await AS.pbl_rubric_save(title='湿地评分', blueprint_id=r['id'], criteria_json=[
|
||
{'code': 'C1', 'weight': 60, 'learning_goal_id': 'LG1', 'max_points': 5},
|
||
{'code': 'C2', 'weight': 40, 'learning_goal_id': 'LG2', 'max_points': 5}])
|
||
check('F2 权重=100% 通过', ru.get('ok') and ru.get('weight_total_ok'), str(ru.get('id')))
|
||
sc = await AS.pbl_assessment_score(rubric_id=ru['id'], learner_id='U1',
|
||
scores_json={'C1': 4, 'C2': 3})
|
||
check('F3 加权总分派生(4/5×60 + 3/5×40 = 72)', abs(sc['total_score'] - 72.0) < 0.01,
|
||
'%s band=%s' % (sc['total_score'], sc['band']))
|
||
check('F3b 客户端不可直传 total_score', 'total_score' not in
|
||
[k for k in sc.keys() if k == 'total_score'] or sc.get('derived'),
|
||
str(sc.get('derived'))[:24])
|
||
rep = await AS.pbl_assessment_report(learner_id='U1')
|
||
check('F4 教师报告可读', rep.get('ok') and 'learners' in rep, str(rep.get('record_count')))
|
||
|
||
# ── G KDB 只读桩 ────────────────────────────────────────────────────
|
||
ks = await KD.pbl_kdb_search(keyword='湿地')
|
||
check('G1 KDB 检索为只读桩', ks.get('writable') is False and 'stub' in ks, ks.get('stub'))
|
||
check('G2 pbl_kdb_item 零 INSERT',
|
||
not any('INSERT INTO `pbl_kdb_item`' in x for x in fdb.log), 'log clean')
|
||
ag = await KD.pbl_research_aggregate(group_by='band')
|
||
check('G3 匿名聚合只读 + k_min=5', ag.get('anonymous') and ag.get('written') is False
|
||
and ag.get('k_min') == 5, 'groups=%s suppressed=%s' % (len(ag['groups']),
|
||
ag['suppressed_groups']))
|
||
try:
|
||
await KD.pbl_research_aggregate(group_by='learner_id')
|
||
check('G4 个体维度聚合被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('G4 个体维度聚合被拒', e.code == 'PBL_AGG_DIM_INVALID', e.code)
|
||
check('G5 无 kdb.write/research.write/sim.* 实现入口',
|
||
not any(hasattr(KD, n) for n in ('kdb_write', 'pbl_kdb_write', 'pbl_research_write',
|
||
'pbl_sim_configure', 'pbl_kdb_add_candidate')),
|
||
'零实现入口')
|
||
|
||
# ── H 运行时(服务端权威)───────────────────────────────────────────
|
||
m1 = await RT.pbl_session_member_add(session_id=1, user_id='U1')
|
||
m2 = await RT.pbl_session_member_add(session_id=1, user_id='U1')
|
||
check('H1 成员重复加入幂等', m2.get('deduped') is True, str(m2.get('members')))
|
||
st = await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1',
|
||
state_json={'completed': True},
|
||
event_type='mission.completed', state_version=999)
|
||
check('H2 客户端 state_version 被忽略', st.get('client_version_ignored') is True
|
||
and st['state_version'] == 1, 'v=%s' % st['state_version'])
|
||
st2 = await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1',
|
||
state_json={'x': 1})
|
||
check('H3 state_version 单调递增', st2['state_version'] == 2, str(st2['state_version']))
|
||
check('H4 单事务 + 裁决/端到端时延实测在预算内', st2['within_budget'] is True
|
||
and st2['transaction'] == 'single', json.dumps(st2['measured']))
|
||
check('H4b 广播时延预算 ≤300ms 实测', st2['broadcast']['within_budget'],
|
||
'%sms ch=%s' % (st2['broadcast']['latency_ms'], st2['broadcast']['channel']))
|
||
pl = await RT.pbl_runtime_event_poll(session_id=1, since_seq=0)
|
||
check('H5 3s 轮询兜底可拉增量事件', pl['count'] >= 1 and pl['last_seq'] >= 1,
|
||
'events=%s interval=%ss' % (pl['count'], pl['poll_interval_seconds']))
|
||
try:
|
||
await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1',
|
||
state_json={'y': 2}, if_state_version=1)
|
||
check('H6 乐观锁冲突被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('H6 乐观锁冲突被拒', e.code == 'PBL_STATE_CONFLICT', e.code)
|
||
for i in range(2, 9):
|
||
await RT.pbl_session_member_add(session_id=1, user_id='U%d' % i)
|
||
try:
|
||
await RT.pbl_session_member_add(session_id=1, user_id='U99')
|
||
check('H7 超 8 人上限被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('H7 超 8 人上限被拒', e.code == 'PBL_SESSION_FULL', e.code)
|
||
check('H8 事件表无 UPDATE/DELETE 出口(append-only)',
|
||
not any(x.startswith('UPDATE `pbl_runtime_event`') or
|
||
x.startswith('DELETE FROM `pbl_runtime_event`') for x in fdb.log), 'clean')
|
||
|
||
# ── I appcodes 幂等 ─────────────────────────────────────────────────
|
||
i1 = await AC.pbl_appcodes_inject()
|
||
i2 = await AC.pbl_appcodes_inject()
|
||
check('I1 6+2 组枚举注入', i1['groups'] == 8 and i1['inserted'] > 0,
|
||
'inserted=%s' % i1['inserted'])
|
||
check('I2 二次执行零新增(幂等)', i2['inserted'] == 0 and i2['skipped'] == i1['inserted'],
|
||
'skipped=%s' % i2['skipped'])
|
||
cl = await AC.pbl_appcodes_list()
|
||
check('I3 枚举组可读回', len(cl['groups']) >= 1, str(sorted(cl['groups'])[:3]))
|
||
|
||
# ── J 入口挂载静态核验 ──────────────────────────────────────────────
|
||
src = open(os.path.join(ROOT, 'apps', 'pbls', 'app', 'pbls.py'), encoding='utf-8').read()
|
||
i_env, i_rbac = src.index('env = ServerEnv()'), src.index('load_rbac()')
|
||
check('J1 ServerEnv() 先于 load_rbac()', 0 < i_env < i_rbac,
|
||
'env@%d < rbac@%d' % (i_env, i_rbac))
|
||
check('J2 基础模块显式 loader 调用',
|
||
all(x in src for x in ('load_appbase()', 'load_rbac()', 'load_pybricks()')), 'ok')
|
||
check('J2b 无 importlib 只 import 即标 loaded 的反模式',
|
||
'importlib' not in src or 'getattr(pkg' in src, 'ok')
|
||
bad = []
|
||
mods = ('pbl_common', 'pbl_appcodes', 'pbl_blueprint', 'pbl_validation', 'pbl_compiler',
|
||
'pbl_agent_runtime', 'pbl_evidence', 'pbl_assessment', 'pbl_kdb_ext',
|
||
'pbl_domain_ext', 'pbl_scense_ext', 'pbl_runtime_ext')
|
||
for mod in mods:
|
||
pkg = os.path.join(ROOT, 'modules', mod, mod)
|
||
init_src = open(os.path.join(pkg, 'init.py'), encoding='utf-8').read()
|
||
exp_src = open(os.path.join(pkg, '__init__.py'), encoding='utf-8').read()
|
||
api_src = open(os.path.join(pkg, 'api.py'), encoding='utf-8').read()
|
||
for n in re.findall(r'async def (pbl_\w+)', api_src):
|
||
if ('env.%s = %s' % (n, n)) not in init_src or n not in exp_src:
|
||
bad.append('%s:%s' % (mod, n))
|
||
check('J3 契约三处同步(定义+导出+env注册)', not bad, '缺=%s' % (bad[:5] or '无'))
|
||
check('J4 12 模块 load_* 均可导入',
|
||
all(hasattr(__import__('importlib').import_module('%s.init' % m), 'load_%s' % m)
|
||
for m in mods), 'ok')
|
||
check('J5 模块嵌套包结构 modules/{m}/{m}/',
|
||
all(os.path.isfile(os.path.join(ROOT, 'modules', m, m, 'api.py')) for m in mods), 'ok')
|
||
check('J6 模块无 app.py / 无独立端口(模块非部署单元)',
|
||
not any(os.path.exists(os.path.join(ROOT, 'modules', m, 'app.py')) or
|
||
os.path.exists(os.path.join(ROOT, 'modules', m, 'app', '%s.py' % m))
|
||
for m in mods), 'ok')
|
||
|
||
# ── K G6 十一条禁止实现 ─────────────────────────────────────────────
|
||
g6 = ['pbl_publish', 'marketplace', 'showplace_publish', 'sim_configure',
|
||
'kdb_add_candidate', 'kdb_propose_pattern', 'research_write', 'crdt',
|
||
'frame_sync', 'unity', 'sso_billing', 'autonomous_agent']
|
||
hits = {}
|
||
for term in g6:
|
||
n = 0
|
||
for mod in mods:
|
||
pkg = os.path.join(ROOT, 'modules', mod, mod)
|
||
for fn in os.listdir(pkg):
|
||
if not fn.endswith('.py'):
|
||
continue
|
||
t = open(os.path.join(pkg, fn), encoding='utf-8').read()
|
||
n += len(re.findall(r'(?:async def|def)\s+\w*%s\w*\s*\(' % term, t, re.I))
|
||
hits[term] = n
|
||
check('K1 G6 十一条禁止实现零命中(无函数定义)',
|
||
all(v == 0 for v in hits.values()), str({k: v for k, v in hits.items() if v}) or 'all 0')
|
||
|
||
# ── L 前端覆盖层 ────────────────────────────────────────────────────
|
||
pr = await SX.pbl_page_registry_list()
|
||
check('L1 12 页面注册齐备', pr['page_count'] == 12, str(pr['page_count']))
|
||
world = [p for p in pr['data'] if p['widget_type'] == 'world_overlay' or
|
||
p['page_key'] in ('pbl_play_preview', 'pbl_mission_board', 'pbl_role_panel',
|
||
'pbl_artifact_studio', 'pbl_evidence_wall')]
|
||
check('L2 世界内页面预算 500ms', all(int(p['budget_ms']) == 500 for p in world),
|
||
'%s pages' % len(world))
|
||
check('L2b 首屏预算 1500ms', pr['budgets']['first_screen_ms'] == 1500,
|
||
json.dumps(pr['budgets']))
|
||
bs = await SX.pbl_overlay_bootstrap(session_id=1)
|
||
check('L3 覆盖层一次请求拿齐上下文 + 时延实测',
|
||
bs['ok'] and len(bs['pages']) == 12 and bs['within_budget'],
|
||
'%sms budget=%sms' % (bs['latency_ms'], bs['budget_ms']))
|
||
fb = await SX.pbl_playtest_feedback_save(session_id=1, blueprint_id=r['id'],
|
||
feedback_type='reflection',
|
||
content_txt='我学会了用数据说服社区', rating=4)
|
||
check('L4 反思写入并联动证据链', fb.get('ok'), str(fb.get('id')))
|
||
try:
|
||
await SX.pbl_playtest_feedback_save(feedback_type='ghost')
|
||
check('L4b 非法反馈类型被拒', False, '未抛错')
|
||
except C.PblError as e:
|
||
check('L4b 非法反馈类型被拒', e.code == 'PBL_FEEDBACK_TYPE_INVALID', e.code)
|
||
ctx = await SX.pbl_session_context_get(session_id=1)
|
||
check('L5 会话上下文只读(authority=server)', ctx['authority'] == 'server'
|
||
and ctx['max_members'] == 8, 'members=%s' % ctx['count'])
|
||
|
||
fails = [x for x in RESULTS if not x[1]]
|
||
print('\n==== 结果:%d 项,通过 %d,失败 %d ====' % (len(RESULTS), len(RESULTS) - len(fails),
|
||
len(fails)))
|
||
for name, _ok, detail in fails:
|
||
print(' FAIL', name, detail)
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(asyncio.get_event_loop().run_until_complete(main()))
|