deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
36a9f79543
commit
f2a3b3eeab
@ -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(),
|
||||
|
||||
@ -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'})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user