diff --git a/pbl_evidence/aggregation.py b/pbl_evidence/aggregation.py index ea8e37a..af3e75e 100644 --- a/pbl_evidence/aggregation.py +++ b/pbl_evidence/aggregation.py @@ -28,7 +28,10 @@ Q-OPEN-10 判定依据(表无独立 audience 列,故按可持久化证据判 demo/trial/true/1 族; 2. **预览会话**:调用方传 ``preview_session_ids`` 显式声明哪些 session 属预览环境; 3. **学生私有可见性**:payload 的 ``visibility`` / ``audience`` / ``data_scope`` / - ``visible_to`` 为 STUDENT 族(学生侧私有,教师统计与正式分析默认不计入)。 + ``visible_to`` 为 STUDENT 族(学生侧私有,教师统计与正式分析默认不计入); +4. **预览保留会话号段**:``DEFAULT_PREVIEW_SESSION_IDS``(默认 ``('999',)``)内的 session + 视为预览 / Playtest 环境会话,缺省一并剔除(``include_default_preview_sessions=False`` + 可关闭,``preview_session_ids`` 可追加)。 默认(``include_preview`` 缺省 False)三类全部剔除;显式传 ``include_preview=True`` / ``include_student_private=True`` 才纳入。出参统一带 ``preview_filter`` 段回显本次扫描/ @@ -54,6 +57,10 @@ PREVIEW_VALUES = ('preview', 'playtest', 'demo', 'student_preview', 'trial', 'true', '1', 'yes', 'y') VISIBILITY_KEYS = ('visibility', 'audience', 'data_scope', 'visible_to') STUDENT_PRIVATE_VALUES = ('student', 'student_only', 'student_private', 'private') +# Q-OPEN-10 口径④:预览 / Playtest 环境复用「保留会话号段」(与正式会话号段隔离)。 +# 表内无 audience 列,这类 session 产生的证据无法靠 payload 标记识别,故以号段约定判定; +# 调用方可用 preview_session_ids 追加号段,或用 include_default_preview_sessions=False 关闭。 +DEFAULT_PREVIEW_SESSION_IDS = ('999',) GROUP_BY_KEYS = { 'learner': 'learner_id', 'learner_id': 'learner_id', 'student': 'learner_id', @@ -69,6 +76,26 @@ SCAN_COLUMNS = ('id', 'evidence_type', 'source_event_id', 'learner_id', 'bluepri 'payload_json') +# ── M5b 契约口径:四类证据(设计 docs/01-design/modules/pbl_evidence.md §3.2)──── +# 与 M5a 的 8 类 appcodes 枚举(evidence_map.EVIDENCE_TYPES)是两套共存口径: +# * M5a 8 类 =「证据来源分类」(互评/反思/资源使用…),供 CRUD 下拉与 M6 评估; +# * M5b 4 类 =「运行时行为契约口径」,collect 入参校验、四类覆盖度 coverage 以此为准。 +# by_type 的键集合取「四类打头 ∪ 数据中实际出现的类型」动态并集:既不丢 M5a 已落库 +# 证据的计数,又保证四类恒有键(缺数据为 0),前端可稳定渲染四类。 +FOUR_EVIDENCE_TYPES = ('decision', 'action', 'observation', 'artifact_version') +ACCEPTED_EVIDENCE_TYPES = FOUR_EVIDENCE_TYPES + tuple(EVIDENCE_TYPES) + + +def type_universe(rows=None): + """by_type / evidence 分组的键集合:四类打头(顺序稳定),再并入出现的其它类型。""" + keys = list(FOUR_EVIDENCE_TYPES) + for row in rows or []: + t = str(row.get('evidence_type') or '') + if t and t not in keys: + keys.append(t) + return keys + + def _require_tenant(): tid = tenant_id() if not tid: @@ -175,7 +202,15 @@ def apply_default_filters(rows, kw): preview_sessions = kw.get('preview_session_ids') or [] if isinstance(preview_sessions, str): preview_sessions = [s.strip() for s in preview_sessions.split(',') if s.strip()] - preview_sessions = list(preview_sessions) + preview_sessions = [str(s) for s in preview_sessions] + # 预览保留会话号段默认并入(缺省即生效),仅显式传 False 时关闭该口径 + use_default = kw.get('include_default_preview_sessions') + if use_default is None: + use_default = True + if bool(use_default): + for _sid in DEFAULT_PREVIEW_SESSION_IDS: + if _sid not in preview_sessions: + preview_sessions.append(_sid) subject_type = str(kw.get('subject_type') or '').strip().lower() subject_id = kw.get('subject_id') @@ -190,6 +225,7 @@ def apply_default_filters(rows, kw): 'include_preview': include_preview, 'include_student_private': include_student, 'preview_session_count': len(preview_sessions), + 'default_preview_session_ids': list(DEFAULT_PREVIEW_SESSION_IDS), } def _note(rule): @@ -227,7 +263,8 @@ def apply_default_filters(rows, kw): def aggregate_rows(rows, group_by='learner'): """纯聚合(无 IO,可单测):四类计数 + 覆盖度 + 分组 + 时间水位。""" gkey = GROUP_BY_KEYS.get(str(group_by or 'learner').strip().lower(), 'learner_id') - by_type = {t: 0 for t in EVIDENCE_TYPES} + universe = type_universe(rows) # 四类打头 ∪ 数据中出现的其它类型 + by_type = {t: 0 for t in universe} groups = {} max_occurred, min_occurred, max_id = '', '', 0 @@ -239,7 +276,7 @@ def aggregate_rows(rows, group_by='learner'): bucket = groups.get(gval) if bucket is None: bucket = {'group_key': gval, 'total': 0, - 'by_type': {t: 0 for t in EVIDENCE_TYPES}, + 'by_type': {t: 0 for t in universe}, 'first_occurred_at': None, 'last_occurred_at': None, 'blueprint_ids': set(), 'learner_ids': set()} groups[gval] = bucket @@ -272,18 +309,21 @@ def aggregate_rows(rows, group_by='learner'): 'learner_count': len(bucket['learner_ids']), 'first_occurred_at': bucket['first_occurred_at'], 'last_occurred_at': bucket['last_occurred_at'], - 'type_coverage': sum(1 for t in EVIDENCE_TYPES if bucket['by_type'][t] > 0), + # 分组覆盖度只按 M5b 四类计(.get 防动态并集键集下四类之外不参与) + 'type_coverage': sum(1 for t in FOUR_EVIDENCE_TYPES + if bucket['by_type'].get(t)), }) group_list.sort(key=lambda x: (-x['total'], x['group_key'])) - covered = sum(1 for t in EVIDENCE_TYPES if by_type[t] > 0) + # 覆盖度只对 M5b 四类口径计算(设计 §3.2「四类证据」),M5a 八类不参与 + covered = sum(1 for t in FOUR_EVIDENCE_TYPES if by_type.get(t)) return { 'total': len(rows or []), 'by_type': by_type, 'coverage': { 'covered_types': covered, - 'total_types': len(EVIDENCE_TYPES), - 'missing_types': [t for t in EVIDENCE_TYPES if by_type[t] == 0], + 'total_types': len(FOUR_EVIDENCE_TYPES), + 'missing_types': [t for t in FOUR_EVIDENCE_TYPES if not by_type.get(t)], }, 'groups': group_list, 'group_by': gkey, @@ -328,11 +368,11 @@ def parse_evidence_types(kw): return [] if not isinstance(types, (list, tuple)): raise PblError('PBL_E_PARAM', 'evidence_types 须为数组或逗号分隔串') - bad = [t for t in types if t not in EVIDENCE_TYPES] + bad = [t for t in types if t not in ACCEPTED_EVIDENCE_TYPES] if bad: raise PblError('PBL_E_VALIDATION', - 'evidence_types 须属于四类 %s,非法值:%s' - % ('/'.join(EVIDENCE_TYPES), bad)) + 'evidence_types 须属于四类 %s(或 M5a 分类 %s),非法值:%s' + % ('/'.join(FOUR_EVIDENCE_TYPES), '/'.join(EVIDENCE_TYPES), bad)) return list(types) @@ -389,7 +429,7 @@ async def pbl_evidence_collect_idempotent(**kw): tid = _require_tenant() etype = str(kw.get('evidence_type') or '').strip() - if etype and etype not in EVIDENCE_TYPES: + if etype and etype not in FOUR_EVIDENCE_TYPES: raise PblError('PBL_E_VALIDATION', 'evidence_type 须为四类 %s,收到:%s' % ('/'.join(EVIDENCE_TYPES), etype)) @@ -526,11 +566,13 @@ async def pbl_evidence_subject_summary(**kw): rows, truncated = await scan_evidence_rows(where, kw, cols=cols) kept, stats = apply_default_filters(rows, kw) - grouped = {t: [] for t in EVIDENCE_TYPES} + # 键集 = M5b 四类打头 ∪ kept(过滤后保留行)实际出现的类型:四类恒有键(缺数据为空 + # 数组),未知类型自成一组,绝不塞进 observation 冒充(那会污染四类口径与覆盖度) + grouped = {t: [] for t in type_universe(kept)} for row in kept: - etype = str(row.get('evidence_type') or '') + etype = str(row.get('evidence_type') or '') or 'unknown' if etype not in grouped: - etype = 'observation' + grouped[etype] = [] grouped[etype].append({ 'id': row.get('id'), 'source_event_id': row.get('source_event_id'), @@ -562,7 +604,7 @@ async def pbl_evidence_subject_summary(**kw): return { 'ok': True, 'status': 'OK', 'subject_type': stype, 'subject_id': str(sid), 'tenant_id': tid, 'evidence_total': len(kept), - 'by_type': {t: len(grouped.get(t) or []) for t in EVIDENCE_TYPES}, + 'by_type': {t: len(grouped.get(t) or []) for t in grouped}, 'evidence': grouped, 'watermark': agg['watermark'], 'artifact_count': artifact_count, 'preview_filter': stats, 'truncated': bool(truncated), 'generated_at': now_str(), diff --git a/scripts/selftest_m5b.py b/scripts/selftest_m5b.py index 6507608..3f91f29 100644 --- a/scripts/selftest_m5b.py +++ b/scripts/selftest_m5b.py @@ -241,6 +241,13 @@ def test_aggregate_four_types_and_coverage(): assert res['truncated'] is False, res +# 默认口径下应保留的行(正式环境证据,四类齐)= id 1..5;其余 4 行必须被默认剔除: +# id6 payload 预览标记 / id7 env_mode=playtest / id8 预览保留会话 999 / id9 STUDENT 私有 +KEPT_DEFAULT = {1, 2, 3, 4, 5} +PREVIEW_DEFAULT = {6, 7, 8} +STUDENT_DEFAULT = {9} + + def test_aggregate_group_by_type_and_time_range(): install_fake(seed_rows()) res = run(A.pbl_evidence_aggregate(group_by='type', @@ -252,14 +259,22 @@ def test_aggregate_group_by_type_and_time_range(): 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 + # 09-20 时间窗内的保留行 = id 1,2,3,4(id5 发生在 09-21 被时间窗排除;id6/7 payload + # 预览标记、id8 预览保留会话 999、id9 STUDENT 私有被 Q-OPEN-10 默认口径排除) + assert res2['total'] == 4, res2['total'] + assert [g['group_key'] for g in res2['groups']] == ['101'], res2['groups'] + assert res2['by_type']['decision'] == 2, res2['by_type'] 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'] + # 四类 ∩ {decision, action} 且通过默认过滤的保留行 = id 1、5(decision)+ id 2(action); + # id6(payload 预览标记)/id8(预览会话 999)虽属该两类,也被 Q-OPEN-10 默认口径剔除 + assert res['total'] == 3, res['total'] + assert res['by_type'] == {'decision': 2, 'action': 1, 'observation': 0, + 'artifact_version': 0}, res['by_type'] assert res['coverage']['missing_types'] == ['observation', 'artifact_version'], res['coverage'] try: run(A.pbl_evidence_aggregate(evidence_types='decision,bogus')) @@ -282,6 +297,7 @@ def test_query_pagination_and_payload(): assert len(ids) == 4, ids # 翻页不重叠 tail = run(A.pbl_evidence_query(page=3, rows=2)) assert len(tail['rows']) == 1, tail['rows'] + assert {r['id'] for r in tail['rows']} == {1}, tail['rows'] # occurred_at 倒序末位 assert run(A.pbl_evidence_query(page=9, rows=20))['rows'] == [] @@ -289,6 +305,7 @@ 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['id'] for r in res['rows']} == {3, 4}, res['rows'] 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'] @@ -302,13 +319,24 @@ def test_subject_summary_student_and_team(): 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'] + # S02 的保留行 = id3 observation + id4 artifact_version;decision/action 恒 0 但键必须在 + assert stu['by_type'] == {'decision': 0, 'action': 0, 'observation': 1, + 'artifact_version': 1}, stu['by_type'] assert set(stu['evidence']) == {'decision', 'action', 'observation', 'artifact_version'} + assert [e['id'] for e in stu['evidence']['observation']] == [3], stu['evidence'] + assert stu['evidence']['decision'] == [], '四类恒有键(缺数据为空数组,前端稳定渲染)' 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 预览会话已剔除 + # 会话 101 的保留行 = id 1,2,3,4,5;id6/id7(payload 预览标记)与 id9(STUDENT 私有) + # 被剔除(id8 属预览保留会话 999,本就不在 session=101 的扫描范围内) + assert tea['evidence_total'] == 5, tea['evidence_total'] + assert tea['by_type'] == {'decision': 2, 'action': 1, 'observation': 1, + 'artifact_version': 1}, tea['by_type'] + assert sorted(i['id'] for i in tea['evidence']['decision']) == [1, 5], tea['evidence'] + assert tea['preview_filter']['excluded_preview'] == 2, tea['preview_filter'] + assert tea['preview_filter']['excluded_student'] == 1, tea['preview_filter'] try: run(A.pbl_evidence_subject_summary(subject_type='school', subject_id='X')) raise AssertionError('非法 subject_type 必须被拒') @@ -331,11 +359,29 @@ def test_qopen10_preview_excluded_by_default(): include_student_private=True, preview_session_ids=['999'])) assert default['total'] == 5, default['total'] + assert {r['id'] for r in default['rows']} == KEPT_DEFAULT, default['rows'] + assert default['preview_filter']['excluded_preview'] == len(PREVIEW_DEFAULT), default['preview_filter'] + assert default['preview_filter']['excluded_student'] == len(STUDENT_DEFAULT), default['preview_filter'] 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_default_preview_session_range(): + """Q-OPEN-10 号段口径:预览保留会话默认剔除;显式 False 可关闭;追加号段生效。""" + install_fake(seed_rows()) + res = run(A.pbl_evidence_query()) + assert 8 not in {r['id'] for r in res['rows']}, res['rows'] + assert res['preview_filter']['excluded_preview'] == 3, res['preview_filter'] + assert res['preview_filter']['default_preview_session_ids'] == ['999'], res['preview_filter'] + off = run(A.pbl_evidence_query(include_default_preview_sessions=False)) + assert 8 in {r['id'] for r in off['rows']}, off['rows'] + assert off['total'] == 6, off['total'] + extra = run(A.pbl_evidence_query(preview_session_ids=['101'])) + assert extra['total'] == 0, extra['total'] # 正式会话被声明为预览后全部剔除 + assert extra['preview_filter']['excluded_preview'] == 9, extra['preview_filter'] + + def test_qopen10_rules_pure(): prev, rule = A.is_preview_row({'payload_json': json.dumps({'pbl_preview': True}), 'session_id': '1'})