409 lines
20 KiB
Python
409 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""M5b 自测(pbl_evidence 证据聚合与查询契约)—— 纯 stdlib,无需 DB / 无需 pytest。
|
||
|
||
覆盖设计 docs/01-design/modules/pbl_evidence.md §3.2 的 M5b 需求点:
|
||
|
||
1. **四类证据幂等采集**:``pbl_evidence_collect_idempotent`` 首次 ``created=True``,
|
||
重复(同 idempotency_key / 同唯一索引三元组)``created=False`` 且不产生第二行;
|
||
2. **idempotency_key 归一**:显式键原样透传;缺省键由 ``tenant|source_event_id|
|
||
evidence_type`` 派生且稳定(重放同键);非四类 evidence_type 报 ``PBL_E_VALIDATION``;
|
||
3. **聚合契约**:``pbl_evidence_aggregate`` 四类计数 + 覆盖度 + 分组 + 时间水位;
|
||
4. **列表查询契约**:``pbl_evidence_query`` 分页 total/rows + payload 解析 + 水位;
|
||
5. **主体档案契约**:``pbl_evidence_subject_summary`` 学生 / 团队四类分组明细;
|
||
6. **Q-OPEN-10**:预览 / Playtest 数据(payload 标记、显式预览会话、STUDENT 私有可见性)
|
||
默认剔除,``include_preview=True`` 才纳入,且 ``preview_filter`` 回显剔除口径;
|
||
7. **租户 fail-closed**:无 tenant_id 直接 ``PBL_E_TENANT_MISSING``,不落到 SQL。
|
||
|
||
内存版 DB 会**校验拼出的 SQL**(列清单、LIMIT 占位、无 ``None`` 泄漏)并按 params 过滤,
|
||
因此不只是「函数不报错」,而是验证了查询参数与 SQL 的对应关系。
|
||
|
||
运行:
|
||
cd modules/pbl_evidence && python3 scripts/selftest_m5b.py
|
||
# 或: python3 -m pytest scripts/selftest_m5b.py -q (test_* 函数可直接被收集)
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
for sib in ('pbl_common',):
|
||
p = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||
os.path.abspath(__file__)))), sib)
|
||
if os.path.isdir(p) and p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from pbl_common.api import PblError # noqa: E402
|
||
import pbl_evidence.aggregation as A # noqa: E402
|
||
|
||
TENANT = '1'
|
||
EVIDENCE_COLS = ['id', 'tenant_id', 'artifact_id', 'evidence_type', 'source_event_id',
|
||
'session_id', 'learner_id', 'blueprint_id', 'payload_json',
|
||
'occurred_at', 'dedup_key', 'collect_batch_id', 'created_at', 'updated_at']
|
||
ARTIFACT_COLS = ['id', 'tenant_id', 'artifact_uid', 'session_id', 'blueprint_id',
|
||
'team_id', 'creator_id', 'title', 'artifact_type', 'content_json',
|
||
'version_no', 'status', 'submitted_at', 'created_at', 'updated_at']
|
||
|
||
_CAPTURED = {'sql': [], 'params': []}
|
||
|
||
|
||
def _row(idx, etype, learner='S01', session='101', blueprint='9', artifact=0,
|
||
occurred='2026-09-20 10:00:0%d' % (idx % 9), payload=None, source=None):
|
||
return {
|
||
'id': idx, 'tenant_id': TENANT, 'artifact_id': artifact, 'evidence_type': etype,
|
||
'source_event_id': source or ('EV%03d' % idx), 'session_id': session,
|
||
'learner_id': learner, 'blueprint_id': blueprint,
|
||
'payload_json': json.dumps(payload or {'event_type': 'mission.completed'},
|
||
ensure_ascii=False),
|
||
'occurred_at': occurred, 'dedup_key': 'K%03d' % idx,
|
||
}
|
||
|
||
|
||
def seed_rows():
|
||
"""正式环境证据 5 条(四类齐)+ 预览/Playtest 证据 4 条(应被默认过滤)。"""
|
||
return [
|
||
_row(1, 'decision'),
|
||
_row(2, 'action'),
|
||
_row(3, 'observation', learner='S02'),
|
||
_row(4, 'artifact_version', artifact=77, learner='S02'),
|
||
_row(5, 'decision', learner='S03', occurred='2026-09-21 08:00:00'),
|
||
# ── 预览 / Playtest 族(Q-OPEN-10 默认剔除)────────────────
|
||
_row(6, 'action', payload={'pbl_preview': True, 'event_type': 'mission.completed'}),
|
||
_row(7, 'observation', payload={'env_mode': 'playtest'}),
|
||
_row(8, 'decision', session='999',
|
||
payload={'event_type': 'team.decision'}), # 显式预览会话 999
|
||
_row(9, 'artifact_version', artifact=88,
|
||
payload={'visibility': 'student', 'content_ref': 'draft'}), # STUDENT 私有
|
||
]
|
||
|
||
|
||
class FakeDB:
|
||
"""内存版证据库:按 params 过滤,同时校验 SQL 文本合法性。"""
|
||
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
|
||
async def table_columns(self, table):
|
||
if table == 'pbl_evidence':
|
||
return list(EVIDENCE_COLS)
|
||
if table == 'pbl_artifact':
|
||
return list(ARTIFACT_COLS)
|
||
return []
|
||
|
||
@staticmethod
|
||
def _match(row, params):
|
||
for key, val in (params or {}).items():
|
||
if key in ('scan_limit', 'time_from', 'time_to') or key.startswith('etype'):
|
||
continue
|
||
if key in row and str(row[key]) != str(val):
|
||
return False
|
||
if 'time_from' in (params or {}) and row.get('occurred_at'):
|
||
if str(row['occurred_at']) < str(params['time_from']):
|
||
return False
|
||
if 'time_to' in (params or {}) and row.get('occurred_at'):
|
||
if str(row['occurred_at']) > str(params['time_to']):
|
||
return False
|
||
types = [v for k, v in (params or {}).items() if k.startswith('etype')]
|
||
if types and str(row.get('evidence_type')) not in [str(t) for t in types]:
|
||
return False
|
||
return True
|
||
|
||
async def q_all(self, sql, params=None):
|
||
_CAPTURED['sql'].append(sql)
|
||
_CAPTURED['params'].append(dict(params or {}))
|
||
low = sql.lower()
|
||
assert 'none' not in low.replace('is null', ''), 'SQL 泄漏 None:%s' % sql
|
||
if 'from `pbl_evidence`' in low:
|
||
assert 'limit ${scan_limit}$' in low, '缺少扫描上限(防全表拖库):%s' % sql
|
||
assert 'tenant_id' in low, '证据查询必须带租户条件:%s' % sql
|
||
limit = int((params or {}).get('scan_limit') or A.MAX_SCAN_ROWS)
|
||
rows = [r for r in self.rows if self._match(r, params)]
|
||
rows.sort(key=lambda r: (str(r.get('occurred_at') or ''), int(r.get('id') or 0)),
|
||
reverse=True)
|
||
return rows[:limit]
|
||
if 'count(*)' in low and 'pbl_artifact' in low:
|
||
return [{'c': 2}]
|
||
return []
|
||
|
||
async def q_one(self, sql, params=None):
|
||
rows = await self.q_all(sql, params)
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def install_fake(rows, tenant=TENANT):
|
||
"""把 aggregation 的 IO 依赖换成内存实现(不碰真实 DB)。"""
|
||
db = FakeDB(rows)
|
||
A.table_columns = db.table_columns
|
||
A.q_all = db.q_all
|
||
A.q_one = db.q_one
|
||
A.tenant_id = lambda *a, **k: tenant
|
||
return db
|
||
|
||
|
||
def run(coro):
|
||
return asyncio.get_event_loop().run_until_complete(coro)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 1. 幂等采集(含 created 布尔 + 重复不产生第二行)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def test_idempotent_collect_created_flag():
|
||
rows = seed_rows()
|
||
install_fake(rows)
|
||
store = {}
|
||
|
||
async def fake_collect(**kw):
|
||
key = (str(kw.get('tenant_id') or TENANT), str(kw.get('source_event_id')),
|
||
str(kw.get('evidence_type')))
|
||
if key in store:
|
||
return {'ok': True, 'deduped': True, 'id': store[key],
|
||
'unique_index': 'uk_ev_dedup', 'evidence_type': key[2]}
|
||
nid = 1000 + len(store)
|
||
store[key] = nid
|
||
rows.append(_row(nid, key[2], source=key[1]))
|
||
return {'ok': True, 'deduped': False, 'id': nid, 'unique_index': 'uk_ev_dedup',
|
||
'evidence_type': key[2]}
|
||
|
||
import pbl_evidence.api as api
|
||
origin = api.pbl_evidence_collect
|
||
api.pbl_evidence_collect = fake_collect
|
||
try:
|
||
first = run(A.pbl_evidence_collect_idempotent(
|
||
source_event_id='EV900', evidence_type='decision', learner_id='S01',
|
||
blueprint_id='9', session_id='101', payload={'choice': 'A'}))
|
||
assert first['created'] is True, first
|
||
assert first['deduped'] is False, first
|
||
assert first['idempotency_key'].startswith('IK') and len(first['idempotency_key']) == 26
|
||
assert first['idempotency_key_derived'] is True, first
|
||
second = run(A.pbl_evidence_collect_idempotent(
|
||
source_event_id='EV900', evidence_type='decision', learner_id='S01',
|
||
blueprint_id='9', session_id='101', payload={'choice': 'A'}))
|
||
assert second['created'] is False, second
|
||
assert second['deduped'] is True, second
|
||
assert second['id'] == first['id'], (first, second)
|
||
assert second['idempotency_key'] == first['idempotency_key'], '派生键必须稳定'
|
||
assert len(store) == 1, '重复采集不得产生第二行:%s' % store
|
||
# 显式 idempotency_key 原样透传
|
||
third = run(A.pbl_evidence_collect_idempotent(
|
||
source_event_id='EV901', evidence_type='action', idempotency_key='IK-EXTERNAL-01',
|
||
learner_id='S01'))
|
||
assert third['idempotency_key'] == 'IK-EXTERNAL-01', third
|
||
assert third['idempotency_key_derived'] is False, third
|
||
finally:
|
||
api.pbl_evidence_collect = origin
|
||
|
||
|
||
def test_collect_rejects_non_fourth_type():
|
||
install_fake(seed_rows())
|
||
try:
|
||
run(A.pbl_evidence_collect_idempotent(source_event_id='EV902',
|
||
evidence_type='research_upload'))
|
||
raise AssertionError('非四类 evidence_type 必须被拒')
|
||
except PblError as exc:
|
||
assert exc.code == 'PBL_E_VALIDATION', exc.code
|
||
|
||
|
||
def test_tenant_fail_closed():
|
||
install_fake(seed_rows(), tenant=None)
|
||
for fn, kw in ((A.pbl_evidence_aggregate, {}), (A.pbl_evidence_query, {}),
|
||
(A.pbl_evidence_subject_summary, {'subject_id': 'S01'}),
|
||
(A.pbl_evidence_collect_idempotent, {'source_event_id': 'EV1'})):
|
||
try:
|
||
run(fn(**kw))
|
||
raise AssertionError('%s 缺租户时必须 fail-closed' % fn.__name__)
|
||
except PblError as exc:
|
||
assert exc.code == 'PBL_E_TENANT_MISSING', (fn.__name__, exc.code)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 2. 聚合契约
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def test_aggregate_four_types_and_coverage():
|
||
install_fake(seed_rows())
|
||
res = run(A.pbl_evidence_aggregate(group_by='learner'))
|
||
assert res['ok'] is True and res['status'] == 'OK', res
|
||
assert res['total'] == 5, res['total'] # 9 行中 4 行预览族被默认剔除
|
||
assert res['by_type'] == {'decision': 2, 'action': 1, 'observation': 1,
|
||
'artifact_version': 1}, res['by_type']
|
||
assert res['coverage']['covered_types'] == 4, res['coverage']
|
||
assert res['coverage']['missing_types'] == [], res['coverage']
|
||
keys = {g['group_key'] for g in res['groups']}
|
||
assert keys == {'S01', 'S02', 'S03'}, keys
|
||
assert res['watermark']['max_occurred_at'] == '2026-09-21 08:00:00', res['watermark']
|
||
assert res['preview_filter']['excluded_preview'] == 3, res['preview_filter']
|
||
assert res['preview_filter']['excluded_student'] == 1, res['preview_filter']
|
||
assert res['preview_filter']['applied'] is True, res['preview_filter']
|
||
assert res['truncated'] is False, res
|
||
|
||
|
||
def test_aggregate_group_by_type_and_time_range():
|
||
install_fake(seed_rows())
|
||
res = run(A.pbl_evidence_aggregate(group_by='type',
|
||
time_from='2026-09-21 00:00:00',
|
||
time_to='2026-09-21 23:59:59'))
|
||
assert res['total'] == 1, res
|
||
assert res['groups'][0]['group_key'] == 'decision', res['groups']
|
||
res2 = run(A.pbl_evidence_aggregate(group_by='session',
|
||
time_range={'from': '2026-09-20 00:00:00',
|
||
'to': '2026-09-20 23:59:59'}))
|
||
assert res2['group_by'] == 'session_id', res2
|
||
assert res2['total'] == 3, res2
|
||
|
||
|
||
def test_aggregate_evidence_types_filter():
|
||
install_fake(seed_rows())
|
||
res = run(A.pbl_evidence_aggregate(evidence_types=['decision', 'action']))
|
||
assert set(res['by_type']) == {'decision', 'action', 'observation', 'artifact_version'}
|
||
assert res['total'] == 2, res['total']
|
||
assert res['coverage']['missing_types'] == ['observation', 'artifact_version'], res['coverage']
|
||
try:
|
||
run(A.pbl_evidence_aggregate(evidence_types='decision,bogus'))
|
||
raise AssertionError('非法 evidence_types 必须被拒')
|
||
except PblError as exc:
|
||
assert exc.code == 'PBL_E_VALIDATION', exc.code
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 3. 列表查询契约(分页 + payload + 水位)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def test_query_pagination_and_payload():
|
||
install_fake(seed_rows())
|
||
res = run(A.pbl_evidence_query(page=1, rows=2))
|
||
assert res['total'] == 5, res['total']
|
||
assert len(res['rows']) == 2, len(res['rows'])
|
||
assert res['rows'][0]['payload'].get('event_type'), res['rows'][0]
|
||
page2 = run(A.pbl_evidence_query(page=2, rows=2))
|
||
ids = {r['id'] for r in res['rows']} ^ {r['id'] for r in page2['rows']}
|
||
assert len(ids) == 4, ids # 翻页不重叠
|
||
tail = run(A.pbl_evidence_query(page=3, rows=2))
|
||
assert len(tail['rows']) == 1, tail['rows']
|
||
assert run(A.pbl_evidence_query(page=9, rows=20))['rows'] == []
|
||
|
||
|
||
def test_query_filters_by_learner_and_type():
|
||
install_fake(seed_rows())
|
||
res = run(A.pbl_evidence_query(learner_id='S02'))
|
||
assert res['total'] == 2, res['total']
|
||
assert {r['learner_id'] for r in res['rows']} == {'S02'}, res['rows']
|
||
res2 = run(A.pbl_evidence_query(evidence_type='decision'))
|
||
assert res2['total'] == 2, res2['total']
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 4. 主体档案契约(学生 / 团队)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def test_subject_summary_student_and_team():
|
||
install_fake(seed_rows())
|
||
stu = run(A.pbl_evidence_subject_summary(subject_type='student', subject_id='S02'))
|
||
assert stu['ok'] is True, stu
|
||
assert stu['evidence_total'] == 2, stu['evidence_total']
|
||
assert stu['by_type']['action'] == 1 and stu['by_type']['artifact_version'] == 1, stu['by_type']
|
||
assert set(stu['evidence']) == {'decision', 'action', 'observation', 'artifact_version'}
|
||
assert stu['artifact_count'] == 2, stu['artifact_count']
|
||
tea = run(A.pbl_evidence_subject_summary(subject_type='team', team_id='101',
|
||
blueprint_id='9'))
|
||
assert tea['subject_id'] == '101', tea
|
||
assert tea['evidence_total'] == 2, tea['evidence_total'] # 999 预览会话已剔除
|
||
try:
|
||
run(A.pbl_evidence_subject_summary(subject_type='school', subject_id='X'))
|
||
raise AssertionError('非法 subject_type 必须被拒')
|
||
except PblError as exc:
|
||
assert exc.code == 'PBL_E_PARAM', exc.code
|
||
try:
|
||
run(A.pbl_evidence_subject_summary(subject_type='student'))
|
||
raise AssertionError('缺 subject_id 必须被拒')
|
||
except PblError as exc:
|
||
assert exc.code == 'PBL_E_PARAM', exc.code
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 5. Q-OPEN-10 预览 / Playtest 默认过滤
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def test_qopen10_preview_excluded_by_default():
|
||
install_fake(seed_rows())
|
||
default = run(A.pbl_evidence_query())
|
||
withprev = run(A.pbl_evidence_query(include_preview=True,
|
||
include_student_private=True,
|
||
preview_session_ids=['999']))
|
||
assert default['total'] == 5, default['total']
|
||
assert withprev['total'] == 9, withprev['total']
|
||
assert withprev['preview_filter']['applied'] is False, withprev['preview_filter']
|
||
assert withprev['by_type']['observation'] == 2, withprev['by_type']
|
||
|
||
|
||
def test_qopen10_rules_pure():
|
||
prev, rule = A.is_preview_row({'payload_json': json.dumps({'pbl_preview': True}),
|
||
'session_id': '1'})
|
||
assert prev and rule == 'payload:pbl_preview', rule
|
||
prev, rule = A.is_preview_row({'payload_json': json.dumps({'env_mode': 'playtest'}),
|
||
'session_id': '1'})
|
||
assert prev and rule == 'payload:env_mode', rule
|
||
prev, rule = A.is_preview_row({'payload_json': json.dumps({'mode': 'demo'}),
|
||
'session_id': '1'})
|
||
assert prev and rule == 'payload:mode', rule
|
||
prev, rule = A.is_preview_row({'payload_json': json.dumps({'event_type': 'x'}),
|
||
'session_id': '999'}, ['999'])
|
||
assert prev and rule == 'preview_session:999', rule
|
||
prev, _ = A.is_preview_row({'payload_json': '{bad json', 'session_id': '1'})
|
||
assert prev is False, '坏 JSON 不得误判为预览'
|
||
priv, rule = A.is_student_private({'payload_json': json.dumps({'visibility': 'STUDENT'})})
|
||
assert priv and rule.startswith('payload:visibility'), rule
|
||
priv, _ = A.is_student_private({'payload_json': json.dumps({'visibility': 'teacher'})})
|
||
assert priv is False
|
||
kept, stats = A.apply_default_filters(
|
||
[{'id': 1, 'payload_json': '{}', 'session_id': '1', 'learner_id': 'S01'}],
|
||
{'subject_type': 'student', 'subject_id': 'S02'})
|
||
assert kept == [] and stats['excluded_subject'] == 1, stats
|
||
key, derived = A.resolve_idempotency_key({'tenant_id': '1', 'source_event_id': 'EV1',
|
||
'evidence_type': 'action'})
|
||
key2, _ = A.resolve_idempotency_key({'tenant_id': '1', 'source_event_id': 'EV1',
|
||
'evidence_type': 'action'})
|
||
assert key == key2 and derived is True, (key, key2)
|
||
assert A.resolve_idempotency_key({'idempotency_key': 'abc'})[0] == 'abc'
|
||
|
||
|
||
def test_sql_shape_and_column_pruning():
|
||
install_fake(seed_rows())
|
||
_CAPTURED['sql'].clear()
|
||
run(A.pbl_evidence_aggregate(group_by='learner'))
|
||
sql = [s for s in _CAPTURED['sql'] if 'from `pbl_evidence`' in s.lower()][0]
|
||
assert 'select `id`, `evidence_type`' in sql.lower(), sql
|
||
assert '`source_event_id`' in sql and '`dedup_key`' in sql and '`payload_json`' in sql, sql
|
||
assert 'order by `occurred_at` desc' in sql.lower(), sql
|
||
assert 'limit ${scan_limit}$' in sql.lower(), sql
|
||
|
||
|
||
def test_watermark_contract():
|
||
install_fake(seed_rows())
|
||
agg = run(A.pbl_evidence_aggregate(group_by='blueprint'))
|
||
wm = agg['watermark']
|
||
assert wm['count'] == 5 and wm['max_id'] == 5, wm
|
||
assert wm['min_occurred_at'] == '2026-09-20 10:00:01', wm
|
||
q = run(A.pbl_evidence_query(learner_id='S03'))
|
||
assert q['watermark']['max_occurred_at'] == '2026-09-21 08:00:00', q['watermark']
|
||
|
||
|
||
ALL_TESTS = sorted([v for k, v in list(globals().items())
|
||
if k.startswith('test_') and callable(v)])
|
||
|
||
|
||
def main():
|
||
passed, failed = 0, 0
|
||
for fn in ALL_TESTS:
|
||
try:
|
||
fn()
|
||
except Exception as exc:
|
||
failed += 1
|
||
print('FAIL %s: %s' % (fn.__name__, exc))
|
||
else:
|
||
passed += 1
|
||
print('PASS %s' % fn.__name__)
|
||
print('\n== M5b selftest: %d passed, %d failed, %d total ==' % (passed, failed, len(ALL_TESTS)))
|
||
return 1 if failed else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|