deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-21 12:38:06 +08:00
parent 7ca7bfdb72
commit a3e8ec8996
10 changed files with 1414 additions and 359 deletions

View File

@ -2,143 +2,37 @@
"summary": [
{
"name": "pbl_artifact",
"comment": "学生产出物(版本内联 version_no",
"module": "pbl_evidence",
"engine": "mariadb",
"charset": "utf8mb4",
"tenant_scoped": true
"title": "学生产出物表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{
"name": "tenant_id",
"comment": "租户ID(强制打头)",
"null": false,
"type": "str",
"length": 32
},
{
"name": "id",
"comment": "主键",
"null": false,
"type": "int",
"length": 20,
"unsigned": true,
"auto_increment": true
},
{
"name": "artifact_uid",
"comment": "产出UID",
"null": false,
"type": "str",
"length": 32
},
{
"name": "session_id",
"comment": "会话",
"null": false,
"type": "bigint"
},
{
"name": "blueprint_id",
"comment": "蓝图",
"null": false,
"type": "bigint"
},
{
"name": "team_id",
"comment": "团队",
"null": false,
"type": "bigint"
},
{
"name": "creator_id",
"comment": "作者",
"null": false,
"type": "str",
"length": 32
},
{
"name": "title",
"comment": "标题",
"null": false,
"type": "str",
"length": 128
},
{
"name": "artifact_type",
"comment": "类型",
"null": false,
"type": "str",
"length": 64
},
{
"name": "content_json",
"comment": "内容",
"null": true,
"type": "json"
},
{
"name": "version_no",
"comment": "版本",
"null": false,
"type": "int"
},
{
"name": "status",
"comment": "状态",
"null": false,
"type": "str",
"length": 64
},
{
"name": "submitted_at",
"comment": "提交时间",
"null": true,
"type": "datetime"
},
{
"name": "created_at",
"comment": "创建时间",
"null": false,
"type": "datetime"
},
{
"name": "updated_at",
"comment": "更新时间",
"null": false,
"type": "datetime"
}
{"name": "id", "title": "主键", "type": "str", "length": 32, "nullable": "no"},
{"name": "tenant_id", "title": "租户ID(强制打头)", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "artifact_uid", "title": "产出UID", "type": "str", "length": 32, "nullable": "no"},
{"name": "session_id", "title": "会话ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "blueprint_id", "title": "蓝图ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "team_id", "title": "团队ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "creator_id", "title": "作者", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "title", "title": "标题", "type": "str", "length": 128, "nullable": "no", "default": ""},
{"name": "artifact_type", "title": "产出物类型", "type": "str", "length": 64, "nullable": "no", "default": "other"},
{"name": "content_json", "title": "内容(序列化JSON文本)", "type": "text", "nullable": "yes"},
{"name": "version_no", "title": "版本号(内联)", "type": "int", "nullable": "no", "default": "1"},
{"name": "status", "title": "状态", "type": "str", "length": 64, "nullable": "no", "default": "draft"},
{"name": "submitted_at", "title": "提交时间", "type": "datetime", "nullable": "yes"},
{"name": "created_at", "title": "创建时间(应用层写入)", "type": "datetime", "nullable": "no"},
{"name": "updated_at", "title": "更新时间(应用层写入)", "type": "datetime", "nullable": "no"}
],
"indexes": [
{
"name": "uk_art_uid",
"unique": true,
"fields": [
"tenant_id",
"artifact_uid"
]
},
{
"name": "idx_art_session",
"unique": false,
"fields": [
"tenant_id",
"session_id"
]
},
{
"name": "PRIMARY",
"unique": true,
"fields": [
"id"
]
}
{"name": "uk_art_uid", "idxtype": "unique", "idxfields": ["tenant_id", "artifact_uid"]},
{"name": "idx_art_session", "idxtype": "index", "idxfields": ["tenant_id", "session_id"]},
{"name": "idx_art_blueprint", "idxtype": "index", "idxfields": ["tenant_id", "blueprint_id"]},
{"name": "idx_art_creator", "idxtype": "index", "idxfields": ["tenant_id", "creator_id"]}
],
"codes": [
{
"field": "artifact_type",
"appcode": "pbl_artifact_type"
}
{"field": "artifact_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_artifact_type'"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_artifact_status'"},
{"field": "blueprint_id", "table": "pbl_blueprint", "valuefield": "id", "textfield": "title"}
]
}

View File

@ -2,131 +2,37 @@
"summary": [
{
"name": "pbl_evidence",
"comment": "学习证据(幂等采集,唯一索引防重)",
"module": "pbl_evidence",
"engine": "mariadb",
"charset": "utf8mb4",
"tenant_scoped": true
"title": "学习证据表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{
"name": "tenant_id",
"comment": "租户ID(强制打头)",
"null": false,
"type": "str",
"length": 32
},
{
"name": "id",
"comment": "主键",
"null": false,
"type": "int",
"length": 20,
"unsigned": true,
"auto_increment": true
},
{
"name": "artifact_id",
"comment": "产出物",
"null": false,
"type": "bigint"
},
{
"name": "evidence_type",
"comment": "证据类型",
"null": false,
"type": "str",
"length": 64
},
{
"name": "source_event_id",
"comment": "来源 runtime_event",
"null": false,
"type": "str",
"length": 32
},
{
"name": "session_id",
"comment": "会话",
"null": false,
"type": "bigint"
},
{
"name": "learner_id",
"comment": "学习者",
"null": false,
"type": "str",
"length": 32
},
{
"name": "blueprint_id",
"comment": "蓝图",
"null": false,
"type": "bigint"
},
{
"name": "payload_json",
"comment": "证据体",
"null": true,
"type": "json"
},
{
"name": "occurred_at",
"comment": "发生时间",
"null": false,
"type": "datetime"
},
{
"name": "dedup_key",
"comment": "去重键",
"null": false,
"type": "str",
"length": 32
},
{
"name": "created_at",
"comment": "创建时间",
"null": false,
"type": "datetime"
},
{
"name": "updated_at",
"comment": "更新时间",
"null": false,
"type": "datetime"
}
{"name": "id", "title": "主键", "type": "str", "length": 32, "nullable": "no"},
{"name": "tenant_id", "title": "租户ID(强制打头)", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "artifact_id", "title": "归属产出物ID(0=无强归属)", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "evidence_type", "title": "证据类型", "type": "str", "length": 64, "nullable": "no", "default": "observation"},
{"name": "source_event_id", "title": "来源runtime_event事件ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "session_id", "title": "会话ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "learner_id", "title": "学习者ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "blueprint_id", "title": "蓝图ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "payload_json", "title": "证据体(序列化JSON文本)", "type": "text", "nullable": "yes"},
{"name": "occurred_at", "title": "发生时间", "type": "datetime", "nullable": "no"},
{"name": "dedup_key", "title": "去重键(md5(tenant|event|type))", "type": "str", "length": 32, "nullable": "no", "default": ""},
{"name": "collect_batch_id", "title": "采集批次ID", "type": "str", "length": 32, "nullable": "no", "default": ""},
{"name": "created_at", "title": "创建时间(应用层写入)", "type": "datetime", "nullable": "no"},
{"name": "updated_at", "title": "更新时间(应用层写入)", "type": "datetime", "nullable": "no"}
],
"indexes": [
{
"name": "uk_ev_dedup",
"unique": true,
"fields": [
"tenant_id",
"source_event_id",
"evidence_type"
]
},
{
"name": "idx_ev_learner",
"unique": false,
"fields": [
"tenant_id",
"learner_id"
]
},
{
"name": "PRIMARY",
"unique": true,
"fields": [
"id"
]
}
{"name": "uk_ev_dedup", "idxtype": "unique", "idxfields": ["tenant_id", "source_event_id", "evidence_type"]},
{"name": "idx_ev_learner", "idxtype": "index", "idxfields": ["tenant_id", "learner_id", "occurred_at"]},
{"name": "idx_ev_artifact", "idxtype": "index", "idxfields": ["tenant_id", "artifact_id"]},
{"name": "idx_ev_type_time", "idxtype": "index", "idxfields": ["tenant_id", "evidence_type", "occurred_at"]},
{"name": "idx_ev_batch", "idxtype": "index", "idxfields": ["tenant_id", "collect_batch_id"]}
],
"codes": [
{
"field": "evidence_type",
"appcode": "pbl_evidence_type"
}
{"field": "evidence_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_evidence_type'"},
{"field": "artifact_id", "table": "pbl_artifact", "valuefield": "id", "textfield": "title"},
{"field": "blueprint_id", "table": "pbl_blueprint", "valuefield": "id", "textfield": "title"}
]
}

View File

@ -1,10 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_evidence —— 产出物与证据幂等采集M5a/M5b
"""pbl_evidence —— 产出物与证据幂等采集模块M5a/M5b
注册三处同步之 必须导出 init.py 里的全部契约函数漏一行 .dspy 调用即 NameError
导出契约函数三处同步之 必须与 init.py env 注册api.py 定义一一对应
漏此处会导致 .dspy 调用 NameError
"""
from pbl_evidence.init import load_pbl_evidence
from pbl_evidence.api import (
pbl_artifact_create,
pbl_artifact_read,
@ -12,18 +13,29 @@ from pbl_evidence.api import (
pbl_artifact_delete,
pbl_artifact_list,
pbl_evidence_collect,
pbl_evidence_collect_from_events,
pbl_evidence_list,
pbl_evidence_stats,
pbl_evidence_watermark,
)
from pbl_evidence.collector import (
CollectError,
collect_evidence_from_events,
resolve_event_table,
)
from pbl_evidence.evidence_map import (
EVIDENCE_TYPES,
EVENT_TO_EVIDENCE,
evidence_types_for_events,
)
__all__ = [
'load_pbl_evidence',
'pbl_artifact_create',
'pbl_artifact_read',
'pbl_artifact_update',
'pbl_artifact_delete',
'pbl_artifact_list',
'pbl_evidence_collect',
'pbl_evidence_list',
'pbl_artifact_create', 'pbl_artifact_read', 'pbl_artifact_update',
'pbl_artifact_delete', 'pbl_artifact_list',
'pbl_evidence_collect', 'pbl_evidence_collect_from_events',
'pbl_evidence_list', 'pbl_evidence_stats', 'pbl_evidence_watermark',
'collect_evidence_from_events', 'resolve_event_table', 'CollectError',
'EVIDENCE_TYPES', 'EVENT_TO_EVIDENCE', 'evidence_types_for_events',
]
__version__ = '1.1.0'

View File

@ -1,129 +1,390 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_evidence.api —— 产出物 + 证据幂等采集第18/19章M5a/M5b
"""pbl_evidence.api —— 产出物 CRUD + 证据幂等采集第18/19章M5a/M5b
幂等硬线`pbl_evidence` 唯一索引 (tenant_id, source_event_id, evidence_type)
重复采集重放/双通道广播 + 3s 轮询兜底同事件 命中已存在行返回 deduped=True
不产生第二条证据也不报错学生侧无感
幂等硬线``pbl_evidence`` 唯一索引 ``uk_ev_dedup(tenant_id, source_event_id, evidence_type)``
重复采集重放 / 双通道广播 + 3s 轮询兜底命中同一事件 命中已存在行返回
``deduped=True``不产生第二条证据也不报错学生侧无感
M5a 修复的两处存量缺陷QC 可核对
1. 旧版 ``from pbl_common.api import crud`` ``pbl_common.api`` **没有** ``crud`` 这个符号
import ``ImportError``模块根本挂不上现改为模块内自建数据访问层``db.py``+
显式列名发现不再依赖不存在的工厂函数
2. 旧版所有读写把库名硬编码成 ``'pbl'`` 违反模块取库名禁止硬编码 DBNAME铁律
库名由宿主应用决定现统一走 ``db.get_dbname()``
``ServerEnv().get_module_dbname('pbl_evidence')``取不到即 fail-closed
契约面init.py 注册 + __init__.py 导出三处同步
产出物pbl_artifact_create / read / update / delete / list
证据 pbl_evidence_collect单事件幂等落库
pbl_evidence_collect_from_events批量 pbl_runtime_event 落证据M5a 主能力
pbl_evidence_list / pbl_evidence_stats / pbl_evidence_watermark
"""
import hashlib
import json
from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec,
sql_rows, tenant_id)
from pbl_common.api import PblError, actor_id, json_dump, now_str, tenant_id
EVIDENCE_TYPES = ['artifact', 'process_event', 'reflection', 'feedback']
from pbl_evidence.db import (
build_insert, build_where, invalidate_cache, json_loads, pick_columns,
q_all, q_exec, q_one, table_columns,
)
from pbl_evidence.collector import (
CollectError, collect_evidence_from_events, evidence_stats as _stats,
list_evidence as _list_ev, resolve_event_table, watermark as _watermark,
)
from pbl_evidence.evidence_map import (
EVIDENCE_TYPES, EV_OBSERVATION, build_dedup_key, map_event_to_evidence_type,
normalize_dt, summarize_payload,
)
# 与 models/pbl_evidence.json 的 evidence_type 编码appcodes.pbl_evidence_type对齐
ALLOWED_EVIDENCE_TYPES = list(EVIDENCE_TYPES)
ARTIFACT_COLS = ('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')
EVIDENCE_WRITE_COLS = ('tenant_id', 'artifact_id', 'evidence_type', 'source_event_id',
'session_id', 'learner_id', 'blueprint_id', 'payload_json',
'occurred_at', 'dedup_key', 'created_at', 'updated_at')
UNIQUE_INDEX_NAME = 'uk_ev_dedup(tenant_id,source_event_id,evidence_type)'
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 {}
def _require_tenant():
tid = tenant_id()
if not tid:
raise PblError('PBL_E_TENANT_MISSING',
'tenant_id 缺失pbl_evidence 所有读写强制带租户fail-closed')
return str(tid)
ART = crud('pbl_artifact', 'pbl', ['artifact_uid', 'session_id', 'blueprint_id', 'team_id',
'creator_id', 'title', 'artifact_type', 'content_json',
'version_no', 'status', 'submitted_at'])
EV = crud('pbl_evidence', 'pbl', ['artifact_id', 'evidence_type', 'source_event_id',
'session_id', 'learner_id', 'blueprint_id', 'payload_json',
'occurred_at', 'dedup_key'])
def _blank(value):
return value is None or (isinstance(value, str) and value.strip() == '')
async def _tid():
return await tenant_id()
def _gen_uid(prefix, seed):
return '%s%s' % (prefix, hashlib.sha256(str(seed).encode('utf-8')).hexdigest()[:20])
# ══════════════════════════════════════════════════════════════════════
# 产出物 pbl_artifact
# ══════════════════════════════════════════════════════════════════════
async def pbl_artifact_create(**kw):
tid = await _tid()
kw.setdefault('artifact_uid', 'AR%s' % hashlib.sha256(
(str(kw.get('title')) + now_str()).encode()).hexdigest()[:20])
kw.setdefault('version_no', 1)
kw.setdefault('status', 'draft')
kw['content_json'] = json_dump(kw.get('content_json'))
kw['creator_id'] = kw.get('creator_id') or await actor_id()
r = await ART['create'](**kw)
r['artifact_uid'] = kw['artifact_uid']
return r
"""新建产出物:自动生成 artifact_uid、version_no=1、status=draft。"""
tid = _require_tenant()
cols = await table_columns('pbl_artifact')
if not cols:
raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_artifact 表不存在,请先执行 models DDL')
title = kw.get('title') or '未命名产出物'
record = {
'tenant_id': tid,
'artifact_uid': kw.get('artifact_uid') or _gen_uid('AR', title + now_str() + str(kw.get('session_id') or '')),
'title': str(title)[:128],
'artifact_type': kw.get('artifact_type') or 'document',
'version_no': int(kw.get('version_no') or 1),
'status': kw.get('status') or 'draft',
'content_json': json_dump(kw.get('content_json') if not _blank(kw.get('content_json')) else {}),
'creator_id': kw.get('creator_id') or actor_id() or '0',
'created_at': now_str(),
'updated_at': now_str(),
}
for key in ('session_id', 'blueprint_id', 'team_id'):
if not _blank(kw.get(key)):
record[key] = str(kw[key])
if not _blank(kw.get('submitted_at')):
st = normalize_dt(kw['submitted_at'])
if st:
record['submitted_at'] = st
record = pick_columns(record, cols)
if 'title' not in record:
raise PblError('PBL_E_DB', 'pbl_artifact 缺 title 列,表结构与模型不一致')
sql, args = build_insert('pbl_artifact', record, cols)
await q_exec(sql, args)
row = await q_one('SELECT `id` FROM `pbl_artifact` WHERE `tenant_id`=${tenant_id}$ '
'AND `artifact_uid`=${artifact_uid}$ LIMIT 1',
{'tenant_id': tid, 'artifact_uid': record['artifact_uid']})
return {'ok': True, 'id': (row or {}).get('id'), 'artifact_uid': record['artifact_uid'],
'version_no': record['version_no'], 'data': {'id': (row or {}).get('id'),
'artifact_uid': record['artifact_uid']}}
async def pbl_artifact_read(**kw):
return await ART['read'](**kw)
"""按 id 或 artifact_uid 读产出物(强制租户过滤)。"""
tid = _require_tenant()
aid = kw.get('id') or kw.get('artifact_id')
uid = kw.get('artifact_uid')
if _blank(aid) and _blank(uid):
raise PblError('PBL_E_PARAM', '缺少 id / artifact_uid')
cols = await table_columns('pbl_artifact')
if _blank(aid):
where, args = {'tenant_id': tid, 'artifact_uid': uid}, None
else:
where, args = {'tenant_id': tid, 'id': aid}, None
where_sql, args = build_where(where, cols)
row = await q_one('SELECT * FROM `pbl_artifact` WHERE %s LIMIT 1' % where_sql, args)
if not row:
raise PblError('PBL_E_NOT_FOUND', '产出物不存在或不属于当前租户')
row['content'] = json_loads(row.get('content_json'), default={})
return {'ok': True, 'data': row}
async def pbl_artifact_update(**kw):
"""改内容即版本递增第29.8 版本化),历史版本快照存 version_history_json 于 content_json 外层。"""
tid = await _tid()
cur = await ART['read'](id=kw.get('id'))
data = cur['data']
payload = dict(kw)
if 'content_json' in payload:
payload['content_json'] = json_dump(payload['content_json'])
try:
hist = _loads(data.get('content_json'), {})
except Exception:
hist = {}
payload['content_json'] = json_dump({'current': _loads(payload['content_json']),
'history': {'v%s' % data['version_no']: hist}})
payload['version_no'] = int(data['version_no'] or 1) + 1
return await ART['update'](**payload)
"""更新产出物:改 content_json 即版本递增第29.8 版本化),旧内容进 history。"""
tid = _require_tenant()
aid = kw.get('id') or kw.get('artifact_id')
if _blank(aid):
raise PblError('PBL_E_PARAM', '缺少 id')
cols = await table_columns('pbl_artifact')
where_sql, args = build_where({'tenant_id': tid, 'id': aid}, cols)
cur = await q_one('SELECT * FROM `pbl_artifact` WHERE %s LIMIT 1' % where_sql, args)
if not cur:
raise PblError('PBL_E_NOT_FOUND', '产出物不存在或不属于当前租户')
upd = {'updated_at': now_str()}
for key in ('title', 'artifact_type', 'status', 'session_id', 'blueprint_id', 'team_id'):
if not _blank(kw.get(key)):
upd[key] = str(kw[key])[:128] if key == 'title' else kw[key]
if not _blank(kw.get('submitted_at')):
st = normalize_dt(kw['submitted_at'])
if st:
upd['submitted_at'] = st
if not _blank(kw.get('content_json')):
new_content = kw['content_json']
if isinstance(new_content, str):
new_content = json_loads(new_content, default={})
old_content = json_loads(cur.get('content_json'), default={})
old_ver = int(cur.get('version_no') or 1)
# 已是本模块写入过的 {current,history} 结构则续写 history否则把原值当 current
prev = old_content.get('current', old_content) if isinstance(old_content, dict) else {}
history = (old_content.get('history') if isinstance(old_content, dict) else {}) or {}
history['v%s' % old_ver] = prev
upd['content_json'] = json_dump({'current': new_content, 'history': history})
upd['version_no'] = old_ver + 1
elif not _blank(kw.get('version_no')):
upd['version_no'] = int(kw['version_no'])
upd = pick_columns(upd, cols)
set_sql = ', '.join('`%s`=${%s}$' % (c, c) for c in upd)
params = dict({c: upd[c] for c in upd})
params['tenant_id'] = tid
params['id'] = aid
await q_exec('UPDATE `pbl_artifact` SET %s WHERE `tenant_id`=${tenant_id}$ AND `id`=${id}$'
% set_sql, params)
return {'ok': True, 'id': aid, 'version_no': upd.get('version_no', cur.get('version_no')),
'updated_fields': sorted(upd.keys())}
async def pbl_artifact_delete(**kw):
tid = await _tid()
await sql_exec('DELETE FROM `pbl_evidence` WHERE `tenant_id` = ${t}$'
' AND `artifact_id` = ${a}$', {'t': tid, 'a': kw.get('id')}, 'pbl')
return await ART['delete'](**kw)
"""删除产出物:先级联删其证据(避免孤儿证据污染 M6 评估),再删本体。"""
tid = _require_tenant()
aid = kw.get('id') or kw.get('artifact_id')
if _blank(aid):
raise PblError('PBL_E_PARAM', '缺少 id')
ev_cols = await table_columns('pbl_evidence')
if 'artifact_id' in ev_cols:
await q_exec('DELETE FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ '
'AND `artifact_id`=${artifact_id}$',
{'tenant_id': tid, 'artifact_id': aid})
cols = await table_columns('pbl_artifact')
where_sql, args = build_where({'tenant_id': tid, 'id': aid}, cols)
await q_exec('DELETE FROM `pbl_artifact` WHERE %s' % where_sql, args)
return {'ok': True, 'id': aid, 'cascaded_evidence': True}
async def pbl_artifact_list(**kw):
return await ART['list'](**kw)
"""产出物分页列表(强制租户;支持 session/blueprint/creator/type/status 过滤)。"""
tid = _require_tenant()
cols = await table_columns('pbl_artifact')
where = {'tenant_id': tid}
for key in ('session_id', 'blueprint_id', 'team_id', 'creator_id',
'artifact_type', 'status', 'artifact_uid'):
if not _blank(kw.get(key)):
where[key] = kw[key]
where_sql, args = build_where(where, cols)
page = max(1, int(kw.get('page') or 1))
rows = min(200, max(1, int(kw.get('rows') or kw.get('page_size') or 20)))
total = int((await q_one('SELECT COUNT(*) AS c FROM `pbl_artifact` WHERE %s' % where_sql,
dict(args)) or {}).get('c') or 0)
sort = kw.get('sort') if (kw.get('sort') in cols) else 'created_at'
direction = 'ASC' if str(kw.get('order') or 'desc').lower() == 'asc' else 'DESC'
args2 = dict(args)
args2['rows'] = rows
args2['offset'] = (page - 1) * rows
recs = await q_all('SELECT * FROM `pbl_artifact` WHERE %s ORDER BY `%s` %s '
'LIMIT ${rows}$ OFFSET ${offset}$' % (where_sql, sort, direction), args2)
for r in recs:
r['content'] = json_loads(r.get('content_json'), default={})
if 'artifact_type' in r:
r['artifact_type_text'] = r.get('artifact_type')
return {'ok': True, 'status': 'OK', 'total': total, 'page': page, 'rows_per_page': rows,
'data': recs, 'rows': recs}
# ══════════════════════════════════════════════════════════════════════
# 证据 pbl_evidence
# ══════════════════════════════════════════════════════════════════════
async def pbl_evidence_collect(**kw):
"""从 pbl_runtime_event 采集证据(服务端权威,客户端不能直接写证据)。"""
tid = await _tid()
etype = kw.get('evidence_type') or 'process_event'
if etype not in EVIDENCE_TYPES:
raise PblError('PBL_EVIDENCE_TYPE_INVALID', 'evidence_type 须为 %s'
% '/'.join(EVIDENCE_TYPES))
src = kw.get('source_event_id')
if not src:
raise PblError('PBL_PARAM_MISSING', '缺少 source_event_id证据必须可溯到事件')
dup = await sql_rows('SELECT * FROM `pbl_evidence` WHERE `tenant_id` = ${t}$'
' AND `source_event_id` = ${s}$ AND `evidence_type` = ${e}$ LIMIT 1',
{'t': tid, 's': src, 'e': etype}, 'pbl')
if dup:
return {'ok': True, 'deduped': True, 'id': dup[0]['id'],
'unique_index': 'uk_ev_dedup(tenant_id,source_event_id,evidence_type)',
'evidence_count': 1}
payload = {'evidence_type': etype, 'source_event_id': src,
'session_id': kw.get('session_id') or 0,
'learner_id': kw.get('learner_id') or await actor_id(),
'blueprint_id': kw.get('blueprint_id'),
'artifact_id': kw.get('artifact_id'),
'payload_json': json_dump(kw.get('payload_json') or {}),
'occurred_at': kw.get('occurred_at') or now_str(),
'dedup_key': hashlib.sha256(('%s|%s' % (src, etype)).encode()).hexdigest()[:32]}
r = await EV['create'](**payload)
r['deduped'] = False
r['unique_index'] = 'uk_ev_dedup(tenant_id,source_event_id,evidence_type)'
return r
"""单事件幂等落证据(服务端权威;客户端不能直接写证据表)。
幂等先按唯一索引三元组预查命中即返回 ``deduped=True``
未命中用 ``INSERT ... ON DUPLICATE KEY UPDATE`` 兜并发竞态
"""
tid = _require_tenant()
src = kw.get('source_event_id') or kw.get('event_id')
if _blank(src):
raise PblError('PBL_E_PARAM', '缺少 source_event_id证据必须可溯到 runtime_event')
src = str(src).strip()[:32]
event_type = kw.get('event_type') or ''
etype = kw.get('evidence_type')
if _blank(etype):
etype = map_event_to_evidence_type(event_type, kw) or EV_OBSERVATION
etype = str(etype).strip()
if etype not in ALLOWED_EVIDENCE_TYPES:
raise PblError('PBL_E_PARAM', 'evidence_type 须为 %s' % '/'.join(ALLOWED_EVIDENCE_TYPES))
cols = await table_columns('pbl_evidence')
if not cols:
raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_evidence 表不存在,请先执行 models DDL')
found = await q_one(
'SELECT `id` FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ '
'AND `source_event_id`=${source_event_id}$ AND `evidence_type`=${evidence_type}$ LIMIT 1',
{'tenant_id': tid, 'source_event_id': src, 'evidence_type': etype})
if found and found.get('id') is not None:
return {'ok': True, 'deduped': True, 'id': found['id'],
'unique_index': UNIQUE_INDEX_NAME, 'evidence_count': 1}
occurred = normalize_dt(kw.get('occurred_at')) or now_str()
record = {
'tenant_id': tid,
'artifact_id': kw.get('artifact_id') if not _blank(kw.get('artifact_id')) else 0,
'evidence_type': etype,
'source_event_id': src,
'session_id': kw.get('session_id') if not _blank(kw.get('session_id')) else 0,
'learner_id': str(kw.get('learner_id') or actor_id() or '0')[:32],
'blueprint_id': kw.get('blueprint_id') if not _blank(kw.get('blueprint_id')) else None,
'payload_json': json_dump(summarize_payload(kw, event_type, etype)),
'occurred_at': occurred,
'dedup_key': build_dedup_key(tid, src, etype),
'created_at': now_str(),
'updated_at': now_str(),
}
record = pick_columns(record, cols)
ins_cols = [c for c in record if c in cols]
col_sql = ', '.join('`%s`' % c for c in ins_cols)
val_sql = ', '.join('${%s}$' % c for c in ins_cols)
dup_cols = [c for c in ins_cols if c not in ('tenant_id', 'source_event_id',
'evidence_type', 'dedup_key', 'created_at')]
sql = 'INSERT INTO `pbl_evidence` (%s) VALUES (%s)' % (col_sql, val_sql)
if dup_cols:
sql += ' ON DUPLICATE KEY UPDATE ' + ', '.join('`%s`=VALUES(`%s`)' % (c, c) for c in dup_cols)
sql += ', `updated_at`=VALUES(`updated_at`)'
else:
sql += ' ON DUPLICATE KEY UPDATE `updated_at`=VALUES(`updated_at`)'
try:
await q_exec(sql, {c: record[c] for c in ins_cols})
except Exception as exc:
if 'duplicate entry' in str(exc).lower() or '1062' in str(exc):
again = await q_one(
'SELECT `id` FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ '
'AND `source_event_id`=${source_event_id}$ AND `evidence_type`=${evidence_type}$ LIMIT 1',
{'tenant_id': tid, 'source_event_id': src, 'evidence_type': etype})
return {'ok': True, 'deduped': True, 'id': (again or {}).get('id'),
'unique_index': UNIQUE_INDEX_NAME, 'evidence_count': 1}
raise PblError('PBL_E_DB', '证据写入失败: %s' % (exc,))
new = await q_one(
'SELECT `id` FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ '
'AND `source_event_id`=${source_event_id}$ AND `evidence_type`=${evidence_type}$ LIMIT 1',
{'tenant_id': tid, 'source_event_id': src, 'evidence_type': etype})
return {'ok': True, 'deduped': False, 'id': (new or {}).get('id'),
'unique_index': UNIQUE_INDEX_NAME, 'evidence_count': 1,
'evidence_type': etype, 'source_event_id': src, 'occurred_at': occurred}
async def pbl_evidence_collect_from_events(**kw):
"""M5a 主能力:批量从 ``pbl_runtime_event``M11b 产物)采集证据并幂等落库。
硬约束 C-3本能力读的事件表由 scense_runtime M11b 写入M5 不得早于 M11b 上线
事件表缺失时 collector ``CollectError`` 这里转成明确错误码不静默返回空成功
"""
tid = _require_tenant()
try:
res = await collect_evidence_from_events(
tenant_id=tid,
since=kw.get('since'),
until=kw.get('until'),
session_id=kw.get('session_id'),
learner_id=kw.get('learner_id'),
blueprint_id=kw.get('blueprint_id'),
event_types=kw.get('event_types') if isinstance(kw.get('event_types'), (list, tuple))
else (str(kw.get('event_types')).split(',') if not _blank(kw.get('event_types')) else None),
evidence_types=kw.get('evidence_types') if isinstance(kw.get('evidence_types'), (list, tuple))
else (str(kw.get('evidence_types')).split(',') if not _blank(kw.get('evidence_types')) else None),
limit=kw.get('limit') or 500,
dry_run=str(kw.get('dry_run') or '').lower() in ('1', 'true', 'yes'),
update_existing=str(kw.get('update_existing') or '').lower() in ('1', 'true', 'yes'),
)
except CollectError as exc:
raise PblError('PBL_E_PARAM' if 'tenant_id' in str(exc) else 'PBL_E_DB_UNAVAILABLE', str(exc))
res['ok'] = True
res['status'] = 'OK'
res['unique_index'] = UNIQUE_INDEX_NAME
res['items'] = res.get('items') or []
if 'message' not in res:
res['message'] = 'scanned=%s created=%s skipped=%s updated=%s ignored=%s failed=%s' % (
res.get('scanned'), res.get('created'), res.get('skipped'),
res.get('updated'), res.get('ignored'), res.get('failed'))
return res
async def pbl_evidence_list(**kw):
tid = await _tid()
where, params = ['`tenant_id` = ${t}$'], {'t': tid}
for col in ('session_id', 'learner_id', 'blueprint_id', 'artifact_id', 'evidence_type'):
if kw.get(col) not in (None, ''):
where.append('`%s` = ${%s}$' % (col, col))
params[col] = kw[col]
limit = min(int(kw.get('page_size') or 50), 200)
rows = await sql_rows('SELECT * FROM `pbl_evidence` WHERE %s ORDER BY `occurred_at` DESC'
' LIMIT ${lim}$' % ' AND '.join(where), dict(params, lim=limit), 'pbl')
"""证据分页查询(强制租户;支持 learner/artifact/session/blueprint/type/时间窗过滤)。"""
tid = _require_tenant()
try:
res = await _list_ev(
tenant_id=tid, learner_id=kw.get('learner_id'), artifact_id=kw.get('artifact_id'),
evidence_type=kw.get('evidence_type'), session_id=kw.get('session_id'),
blueprint_id=kw.get('blueprint_id'), since=kw.get('since'), until=kw.get('until'),
page=kw.get('page') or 1, rows=kw.get('rows') or kw.get('page_size') or 20,
sort=kw.get('sort') or 'occurred_at', order=kw.get('order') or 'desc')
except CollectError as exc:
raise PblError('PBL_E_DB_UNAVAILABLE', str(exc))
rows = res.get('rows') or []
by_type = {}
for r in rows:
by_type[r['evidence_type']] = by_type.get(r['evidence_type'], 0) + 1
return {'ok': True, 'total': len(rows), 'by_type': by_type, 'data': rows}
t = r.get('evidence_type')
by_type[t] = by_type.get(t, 0) + 1
return {'ok': True, 'status': 'OK', 'total': res.get('total'), 'page': res.get('page'),
'rows_per_page': res.get('rows_per_page'), 'by_type': by_type,
'data': rows, 'rows': rows}
async def pbl_evidence_stats(**kw):
"""证据类型分布统计(供 M6 评估与概览页)。"""
tid = _require_tenant()
try:
res = await _stats(tenant_id=tid, learner_id=kw.get('learner_id'),
blueprint_id=kw.get('blueprint_id'))
except CollectError as exc:
raise PblError('PBL_E_DB_UNAVAILABLE', str(exc))
res['ok'] = True
res['status'] = 'OK'
return res
async def pbl_evidence_watermark(**kw):
"""增量采集游标(已采集最大事件时间 + 总量),供 3s 轮询兜底 / cron 回放。"""
tid = _require_tenant()
try:
res = await _watermark(tenant_id=tid)
except CollectError as exc:
raise PblError('PBL_E_DB_UNAVAILABLE', str(exc))
res['ok'] = True
res['status'] = 'OK'
res['event_table'] = await resolve_event_table()
return res

418
pbl_evidence/collector.py Normal file
View File

@ -0,0 +1,418 @@
"""M5a 证据采集核心:从 ``pbl_runtime_event`` 采集学习证据,幂等落 ``pbl_evidence``。
幂等契约任务硬要求
``uk_ev_dedup(tenant_id, source_event_id, evidence_type)`` 唯一索引防重
双保险实现任一生效即保证幂等**并发安全**
1. 预查按唯一索引三元组 SELECT命中则跳过/按需更新省一次写
2. 真防重``INSERT ... ON DUPLICATE KEY UPDATE``唯一索引冲突不报错不产生重复行
由数据库保证并发下最终一致预查与写入之间的竞态由第 2 层兜住
采集是**只读事件表 + 幂等写证据表**可任意次重放M5b 回放cron 轮询兜底同一条路径
"""
from .db import (
get_dbname, q_all, q_one, q_exec, table_exists, table_columns,
pick_columns, build_insert, build_where, json_dumps, json_loads,
row_to_dict, invalidate_cache, _log,
)
from .evidence_map import (
EV_OBSERVATION, EVIDENCE_TYPES, IGNORED_EVENTS, EVENT_ID_FIELDS,
EVENT_TYPE_FIELDS, EVENT_PAYLOAD_FIELDS, EVENT_TIME_FIELDS,
EVENT_LEARNER_FIELDS, EVENT_SESSION_FIELDS, EVENT_BLUEPRINT_FIELDS,
normalize_dt, now_str, first_present, map_event_to_evidence_type,
build_dedup_key, event_source_id, summarize_payload,
)
# 运行时事件表候选名M11b 落 pbl_runtime_event历史/别名兼容 runtime_event。
EVENT_TABLE_CANDIDATES = ('pbl_runtime_event', 'runtime_event', 'pbl_event')
# 单次采集扫描上限(防止一次拉爆内存;分区表按 occurred_at 倒序取最近窗口)
DEFAULT_BATCH_LIMIT = 500
MAX_BATCH_LIMIT = 2000
# 证据表写入前必须存在的列(缺列 = 表未建/未迁移fail-closed
REQUIRED_EVIDENCE_COLS = ('tenant_id', 'source_event_id', 'evidence_type',
'dedup_key', 'occurred_at')
class CollectError(Exception):
"""采集前置条件不满足(表缺失 / 租户缺失等fail-closed。"""
async def resolve_event_table():
"""发现真实的事件表名M11b 落库后的表),找不到返回 None。"""
for name in EVENT_TABLE_CANDIDATES:
if await table_exists(name):
return name
return None
async def collect_evidence_from_events(tenant_id,
since=None,
until=None,
session_id=None,
learner_id=None,
blueprint_id=None,
event_types=None,
evidence_types=None,
limit=DEFAULT_BATCH_LIMIT,
dry_run=False,
update_existing=False):
"""从运行时事件表采集学习证据并幂等落库。
参数
----
tenant_id : 必填缺失即 fail-closed多租户铁律
since/until : 事件 occurred_at 时间窗'YYYY-MM-DD HH:MM:SS'可空
session_id / learner_id / blueprint_id : 缩小扫描范围可空
event_types : 只采集指定事件类型list[str]可空=全部忽略黑名单
evidence_types : 只落指定证据类型list[str]可空=全部
limit : 单次扫描事件上限2000
dry_run : True 只返回将要落库的证据不落库供预检/测试
update_existing: True 已存在的证据会刷新 payload_json/occurred_at默认只跳过
返回 dictscanned / created / skipped / updated / ignored / failed /
created_ids / items / event_table / watermark
"""
tid = (str(tenant_id).strip() if tenant_id else '')
if not tid:
raise CollectError('tenant_id 缺失pbl_evidence 采集 fail-closed拒绝执行')
limit = int(limit or DEFAULT_BATCH_LIMIT)
if limit <= 0:
limit = DEFAULT_BATCH_LIMIT
if limit > MAX_BATCH_LIMIT:
limit = MAX_BATCH_LIMIT
ev_table = await resolve_event_table()
if not ev_table:
raise CollectError(
'未找到运行时事件表(候选 %s。M5a 硬依赖 scense_runtime M11b'
'pbl_runtime_event 建表并落库后方可采集。' % (', '.join(EVENT_TABLE_CANDIDATES),))
ev_cols = await table_columns(ev_table)
if not ev_cols:
raise CollectError('事件表 %s 列信息为空(无权限或表结构异常)' % ev_table)
tgt_cols = await table_columns('pbl_evidence')
if not tgt_cols:
raise CollectError('证据表 pbl_evidence 不存在或无列信息,请先执行 models/pbl_evidence.json 的 DDL')
missing = [c for c in REQUIRED_EVIDENCE_COLS if c not in tgt_cols]
if missing:
raise CollectError('pbl_evidence 缺关键列 %s,请先完成表迁移' % (', '.join(missing),))
ev_filter = None
if evidence_types:
bad = [t for t in evidence_types if t not in EVIDENCE_TYPES]
if bad:
raise CollectError('未知 evidence_type: %s(合法值 %s'
% (', '.join(bad), ', '.join(EVIDENCE_TYPES)))
ev_filter = set(evidence_types)
id_col = first_present_col(ev_cols, EVENT_ID_FIELDS)
type_col = first_present_col(ev_cols, EVENT_TYPE_FIELDS)
payload_col = first_present_col(ev_cols, EVENT_PAYLOAD_FIELDS)
time_col = first_present_col(ev_cols, EVENT_TIME_FIELDS)
learner_col = first_present_col(ev_cols, EVENT_LEARNER_FIELDS)
session_col = first_present_col(ev_cols, EVENT_SESSION_FIELDS)
blueprint_col = first_present_col(ev_cols, EVENT_BLUEPRINT_FIELDS)
if not id_col:
raise CollectError('事件表 %s 无事件主键列(候选 %s),无法构造幂等键'
% (ev_table, ', '.join(EVENT_ID_FIELDS)))
# ── 扫描事件(列名全部来自 information_schema 发现结果,不猜列)────────
select_cols = [id_col]
for c in (type_col, payload_col, time_col, learner_col, session_col, blueprint_col):
if c and c not in select_cols:
select_cols.append(c)
# 事件表可能自带的业务外键,一并带出供证据溯源
for c in ('artifact_id', 'task_id', 'blueprint_version_id', 'scene_id',
'score', 'result', 'status', 'duration_ms', 'tenant_id'):
if c in ev_cols and c not in select_cols:
select_cols.append(c)
conds, args = [], {'limit': limit}
if 'tenant_id' in ev_cols:
conds.append('`tenant_id`=${tenant_id}$')
args['tenant_id'] = tid
if time_col:
sdt, edt = normalize_dt(since), normalize_dt(until)
if sdt:
conds.append('`%s` >= ${since}$' % time_col)
args['since'] = sdt
if edt:
conds.append('`%s` <= ${until}$' % time_col)
args['until'] = edt
if session_id and session_col:
conds.append('`%s`=${session_id}$' % session_col)
args['session_id'] = str(session_id)
if learner_id and learner_col:
conds.append('`%s`=${learner_id}$' % learner_col)
args['learner_id'] = str(learner_id)
if blueprint_id and blueprint_col:
conds.append('`%s`=${blueprint_id}$' % blueprint_col)
args['blueprint_id'] = str(blueprint_id)
if event_types:
etl = [str(t).strip() for t in event_types if str(t).strip()]
if etl and type_col:
ph = []
for i, v in enumerate(etl):
key = 'et_%d' % i
ph.append('${%s}$' % key)
args[key] = v
conds.append('LOWER(`%s`) IN (%s)' % (type_col, ', '.join(ph)))
where_sql = ' AND '.join(conds) if conds else '1=1'
sql = 'SELECT %s FROM `%s` WHERE %s' % (
', '.join('`%s`' % c for c in select_cols), ev_table, where_sql)
if time_col:
sql += ' ORDER BY `%s` ASC' % time_col
sql += ' LIMIT ${limit}$'
try:
events = await q_all(sql, args)
except Exception as exc:
raise CollectError('扫描事件表失败: %s' % (exc,))
stats = {'scanned': len(events), 'created': 0, 'skipped': 0, 'updated': 0,
'ignored': 0, 'failed': 0, 'created_ids': [], 'items': [],
'event_table': ev_table, 'watermark': None, 'dry_run': bool(dry_run)}
now = now_str()
for row in events:
try:
src_id = event_source_id(row)
if not src_id:
stats['ignored'] += 1
continue
event_type = str(first_present(row, (type_col,) if type_col else EVENT_TYPE_FIELDS) or '')
if event_type.lower() in IGNORED_EVENTS:
stats['ignored'] += 1
continue
evidence_type = map_event_to_evidence_type(event_type, row)
if not evidence_type:
stats['ignored'] += 1
continue
if ev_filter and evidence_type not in ev_filter:
stats['ignored'] += 1
continue
occurred = normalize_dt(first_present(row, (time_col,) if time_col else EVENT_TIME_FIELDS)) or now
stats['watermark'] = occurred
learner = first_present(row, (learner_col,) if learner_col else EVENT_LEARNER_FIELDS)
session = first_present(row, (session_col,) if session_col else EVENT_SESSION_FIELDS)
blueprint = first_present(row, (blueprint_col,) if blueprint_col else EVENT_BLUEPRINT_FIELDS)
artifact = row.get('artifact_id') if 'artifact_id' in row else None
payload = summarize_payload(row, event_type, evidence_type)
record = {
'tenant_id': tid,
'source_event_id': src_id,
'evidence_type': evidence_type,
'dedup_key': build_dedup_key(tid, src_id, evidence_type),
'occurred_at': occurred,
'payload_json': json_dumps(payload),
'updated_at': now,
'created_at': now,
}
if learner not in (None, ''):
record['learner_id'] = str(learner)[:32]
if session not in (None, ''):
record['session_id'] = str(session)
if blueprint not in (None, ''):
record['blueprint_id'] = str(blueprint)
if artifact not in (None, ''):
record['artifact_id'] = str(artifact)
# artifact_id 为 NOT NULL行为类证据协作/反思/观测)无强归属产出物时以 0 占位,
# 不因此丢事件——证据链完整性优先于归属字段M6 评估侧按 artifact_id>0 过滤)。
record.setdefault('artifact_id', 0)
record = pick_columns(record, tgt_cols)
stats['items'].append(record)
if dry_run:
stats['skipped'] += 1
continue
action = await _upsert_evidence(record, tgt_cols, update_existing)
stats[action] = stats.get(action, 0) + 1
if action == 'created' and record.get('id'):
stats['created_ids'].append(record['id'])
except Exception as exc:
stats['failed'] += 1
_log('collect one event failed: %s' % (exc,))
return stats
def first_present_col(cols, candidates):
"""在真实列集合中按候选顺序挑第一个存在的列名。"""
for c in candidates:
if c in cols:
return c
return None
async def _upsert_evidence(record, tgt_cols, update_existing=False):
"""幂等写入:预查 + ON DUPLICATE KEY UPDATE 双保险。返回 created/skipped/updated。"""
tid = record['tenant_id']
sid = record['source_event_id']
etp = record['evidence_type']
existing = await q_one(
'SELECT `id` FROM `pbl_evidence` '
'WHERE `tenant_id`=${tenant_id}$ AND `source_event_id`=${source_event_id}$ '
'AND `evidence_type`=${evidence_type}$ LIMIT 1',
{'tenant_id': tid, 'source_event_id': sid, 'evidence_type': etp})
if existing and existing.get('id') is not None:
eid = existing['id']
if update_existing:
upd = {k: v for k, v in record.items()
if k in ('payload_json', 'occurred_at', 'artifact_id',
'session_id', 'learner_id', 'blueprint_id', 'dedup_key')}
upd['updated_at'] = now_str()
cols = [c for c in upd if c in tgt_cols]
if cols:
set_sql = ', '.join('`%s`=${%s}$' % (c, c) for c in cols)
args = {c: upd[c] for c in cols}
args['id'] = eid
args['tenant_id'] = tid
await q_exec('UPDATE `pbl_evidence` SET %s WHERE `id`=${id}$ '
'AND `tenant_id`=${tenant_id}$' % set_sql, args)
return 'updated'
return 'skipped'
# 真防重:唯一索引冲突不报错(并发/重放安全)
cols = [c for c in record if c in tgt_cols]
col_sql = ', '.join('`%s`' % c for c in cols)
val_sql = ', '.join('${%s}$' % c for c in cols)
dup_cols = [c for c in cols if c not in ('tenant_id', 'id', 'created_at',
'source_event_id', 'evidence_type',
'dedup_key')]
sql = 'INSERT INTO `pbl_evidence` (%s) VALUES (%s)' % (col_sql, val_sql)
if dup_cols:
sql += ' ON DUPLICATE KEY UPDATE ' + ', '.join(
'`%s`=VALUES(`%s`)' % (c, c) for c in dup_cols)
sql += ', `updated_at`=VALUES(`updated_at`)'
else:
sql += ' ON DUPLICATE KEY UPDATE `updated_at`=VALUES(`updated_at`)'
try:
await q_exec(sql, {c: record[c] for c in cols})
except Exception as exc:
# 唯一键冲突在部分驱动下仍会抛:视为幂等命中,不算失败
if _is_dup_key(exc):
return 'skipped'
raise
new_id = await q_one(
'SELECT `id` FROM `pbl_evidence` '
'WHERE `tenant_id`=${tenant_id}$ AND `source_event_id`=${source_event_id}$ '
'AND `evidence_type`=${evidence_type}$ LIMIT 1',
{'tenant_id': tid, 'source_event_id': sid, 'evidence_type': etp})
if new_id and new_id.get('id') is not None:
record['id'] = new_id['id']
return 'created'
def _is_dup_key(exc):
text = str(exc).lower()
return 'duplicate entry' in text or '1062' in text or 'unique constraint' in text
async def list_evidence(tenant_id, learner_id=None, artifact_id=None,
evidence_type=None, session_id=None, blueprint_id=None,
since=None, until=None, page=1, rows=20, sort='occurred_at',
order='desc'):
"""证据分页查询(全部条件强制带 tenant_id"""
tid = (str(tenant_id).strip() if tenant_id else '')
if not tid:
raise CollectError('tenant_id 缺失:证据查询 fail-closed')
cols = await table_columns('pbl_evidence')
if not cols:
raise CollectError('pbl_evidence 表不存在,无法查询')
where = {'tenant_id': tid}
for key, val in (('learner_id', learner_id), ('artifact_id', artifact_id),
('evidence_type', evidence_type), ('session_id', session_id),
('blueprint_id', blueprint_id)):
if val not in (None, ''):
where[key] = val
where_sql, args = build_where(where, cols)
sdt, edt = normalize_dt(since), normalize_dt(until)
if sdt and 'occurred_at' in cols:
where_sql += ' AND `occurred_at` >= ${since}$'
args['since'] = sdt
if edt and 'occurred_at' in cols:
where_sql += ' AND `occurred_at` <= ${until}$'
args['until'] = edt
sort_col = sort if sort in cols else 'occurred_at'
direction = 'ASC' if str(order).lower() == 'asc' else 'DESC'
page = max(1, int(page or 1))
rows = min(500, max(1, int(rows or 20)))
total_row = await q_one('SELECT COUNT(*) AS c FROM `pbl_evidence` WHERE %s' % where_sql,
dict(args))
total = int((total_row or {}).get('c') or 0)
args = dict(args)
args['offset'] = (page - 1) * rows
args['rows'] = rows
select_cols = [c for c in ('id', 'tenant_id', 'artifact_id', 'evidence_type',
'source_event_id', 'session_id', 'learner_id',
'blueprint_id', 'occurred_at', 'payload_json',
'created_at') if c in cols]
recs = await q_all(
'SELECT %s FROM `pbl_evidence` WHERE %s ORDER BY `%s` %s LIMIT ${rows}$ OFFSET ${offset}$'
% (', '.join('`%s`' % c for c in select_cols), where_sql, sort_col, direction), args)
out = []
for r in recs:
item = {k: v for k, v in r.items()}
if 'payload_json' in item:
item['payload'] = json_loads(item.get('payload_json'), default={})
try:
item.pop('payload_json')
except Exception:
pass
if 'evidence_type' in item:
item['evidence_type_text'] = item.get('evidence_type')
out.append(item)
return {'rows': out, 'total': total, 'page': page, 'rows_per_page': rows}
async def evidence_stats(tenant_id, learner_id=None, blueprint_id=None):
"""证据类型分布统计(供 M6 评估 / 概览页)。"""
tid = (str(tenant_id).strip() if tenant_id else '')
if not tid:
raise CollectError('tenant_id 缺失:统计 fail-closed')
cols = await table_columns('pbl_evidence')
where = {'tenant_id': tid}
for key, val in (('learner_id', learner_id), ('blueprint_id', blueprint_id)):
if val not in (None, ''):
where[key] = val
where_sql, args = build_where(where, cols)
rows = await q_all(
'SELECT `evidence_type`, COUNT(*) AS c, MAX(`occurred_at`) AS last_at '
'FROM `pbl_evidence` WHERE %s GROUP BY `evidence_type` '
'ORDER BY c DESC' % where_sql, args)
total = sum(int(r.get('c') or 0) for r in rows)
return {'total': total,
'by_type': [{'evidence_type': r.get('evidence_type'),
'count': int(r.get('c') or 0),
'last_at': str(r.get('last_at') or '')} for r in rows]}
async def watermark(tenant_id):
"""已采集到的最大事件时间(增量采集游标,供 cron 轮询兜底)。"""
tid = (str(tenant_id).strip() if tenant_id else '')
if not tid:
raise CollectError('tenant_id 缺失')
cols = await table_columns('pbl_evidence')
if 'occurred_at' not in cols:
return {'last_occurred_at': None, 'count': 0}
row = await q_one(
'SELECT MAX(`occurred_at`) AS last_at, COUNT(*) AS c '
'FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$', {'tenant_id': tid})
return {'last_occurred_at': str((row or {}).get('last_at') or '') or None,
'count': int((row or {}).get('c') or 0)}

252
pbl_evidence/db.py Normal file
View File

@ -0,0 +1,252 @@
"""pbl_evidence 数据访问适配层M5a
铁律 skill/SKILL.mdQC 按同一规范审核
1. **库名由宿主应用决定**只用 ``ServerEnv().get_module_dbname('pbl_evidence')``
模块内绝不硬编码 DBNAME / 'pbl' / 'sage'取不到即 fail-closed 抛错
2. **sqlor 只有 C/U/D/R/I/sqlExe**本层统一走 ``sqlExe`` + ``${param}$`` 占位符
不编造 sor.save/list/query/one/insert/delete 等不存在的 API
3. **``return`` 不能在 ``async with`` 块内**sqlor 上下文里 return 会静默变成
NoneType故一律块内取数 块外 return
4. **sqlExe 返回 DictObject**``dict(row)`` 会抛 ValueError统一经 ``row_to_dict``
5. **多租户隔离**``tenant_id`` 缺失即 fail-closed不查库
6. **列名不猜**所有表列名先经 information_schema 发现``table_columns``
再按交集拼 SQL避免 Unknown column 类错误历史高频缺陷
"""
import importlib
import json as _json
_MODULE_NAME = 'pbl_evidence'
_DBPOOLS = None
_COL_CACHE = {}
_TABLE_CACHE = {}
def _log(msg):
try:
from appPublic.log import debug as _debug
_debug('[pbl_evidence.db] %s' % (msg,))
except Exception:
print('[pbl_evidence.db] %s' % (msg,))
def json_dumps(obj):
"""安全序列化datetime 转字符串,异常降级为空 JSON 对象。"""
try:
return _json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
return '{}'
def json_loads(text, default=None):
"""安全反序列化:兼容 None / 空串 / 已是 dict / 非法 JSON。"""
if text is None or text == '':
return default if default is not None else {}
if isinstance(text, (dict, list)):
return text
try:
return _json.loads(text)
except Exception:
return default if default is not None else {}
def get_dbname():
"""取本模块库名(宿主应用决定,禁止硬编码)。"""
try:
from ahserver.serverenv import ServerEnv
fn = getattr(ServerEnv(), 'get_module_dbname', None)
if callable(fn):
dbname = fn(_MODULE_NAME)
if dbname:
return dbname
except Exception as exc:
_log('get_module_dbname via ServerEnv failed: %s' % (exc,))
raise RuntimeError(
'宿主应用未在 ServerEnv 注册 get_module_dbname'
'pbl_evidence 无法确定库名fail-closed禁止回退硬编码库名'
)
def _dbpools():
"""延迟定位 sqlor DBPools@SingletonDecorator不可传参构造"""
global _DBPOOLS
if _DBPOOLS is not None:
return _DBPOOLS
try:
from ahserver.serverenv import ServerEnv
fn = getattr(ServerEnv(), 'DBPools', None)
if callable(fn):
_DBPOOLS = fn
return fn
except Exception:
pass
for mod, attr in (('sqlor', 'DBPools'), ('sqlor.dbPool', 'DBPools'),
('sqlor.dbpool', 'DBPools'), ('sqlor.dbPools', 'DBPools'),
('sqlor.DBPools', 'DBPools')):
try:
fn = getattr(importlib.import_module(mod), attr, None)
if callable(fn):
_DBPOOLS = fn
return fn
except Exception:
continue
raise RuntimeError('无法定位 sqlor DBPoolsfail-closed')
def row_to_dict(row):
"""DictObject → dict``dict(r)`` 在 DictObject 上会抛 ValueError"""
if row is None:
return {}
if isinstance(row, dict):
return dict(row)
for meth in ('toDict', 'todict', 'as_dict', 'asDict'):
fn = getattr(row, meth, None)
if callable(fn):
try:
return dict(fn())
except Exception:
continue
keys = getattr(row, 'keys', None)
if callable(keys):
try:
return {k: getattr(row, k) for k in list(keys())}
except Exception:
pass
out = {}
state = getattr(row, '__dict__', None)
if isinstance(state, dict):
for k, v in state.items():
if not str(k).startswith('_'):
out[k] = v
return out
async def q_all(sql, params=None):
"""执行查询,返回 list[dict]。"""
dbname = get_dbname()
args = dict(params or {})
async with _dbpools()().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(sql, args)
return [row_to_dict(r) for r in (rows or [])]
async def q_one(sql, params=None):
"""执行查询,返回首行 dict 或 None。"""
rows = await q_all(sql, params)
return rows[0] if rows else None
async def q_exec(sql, params=None):
"""执行 DMLINSERT/UPDATE/DELETE返回受影响行信息不依赖其返回值"""
dbname = get_dbname()
args = dict(params or {})
async with _dbpools()().sqlorContext(dbname) as sor:
res = await sor.sqlExe(sql, args)
return res
async def table_exists(table):
"""表是否存在(跨 schema 检查,用于 runtime_event 表名兼容)。"""
key = table
if key in _TABLE_CACHE:
return _TABLE_CACHE[key]
try:
rows = await q_all(
'SELECT COUNT(*) AS c FROM information_schema.TABLES '
'WHERE TABLE_NAME=${t}$', {'t': table})
cnt = 0
if rows:
cnt = int(list(rows[0].values())[0] or 0)
except Exception as exc:
_log('table_exists(%s) failed: %s' % (table, exc))
cnt = 0
_TABLE_CACHE[key] = cnt > 0
return cnt > 0
async def table_columns(table):
"""发现真实列名集合(禁止凭记忆猜列名)。"""
dbname = get_dbname()
key = (dbname, table)
if key in _COL_CACHE:
return _COL_CACHE[key]
cols = set()
try:
rows = await q_all(
'SELECT COLUMN_NAME AS col FROM information_schema.COLUMNS '
'WHERE TABLE_SCHEMA=${db}$ AND TABLE_NAME=${t}$',
{'db': dbname, 't': table})
for r in rows:
name = r.get('col') or r.get('COLUMN_NAME') or r.get('column_name')
if name:
cols.add(str(name))
except Exception as exc:
_log('table_columns(%s) failed: %s' % (table, exc))
_COL_CACHE[key] = cols
return cols
def pick_columns(data, available):
"""按真实列过滤入参字典(同时挡掉 _text 后缀等前端附带字段)。"""
out = {}
for k, v in (data or {}).items():
if k not in available:
continue
if str(k).endswith('_text'):
continue
out[k] = v
return out
def build_insert(table, data, available):
"""构造 INSERT显式列名禁止 positional VALUES"""
cols = [c for c in data if c in available]
if not cols:
raise RuntimeError('build_insert(%s): 无可写入列' % (table,))
col_sql = ', '.join('`%s`' % c for c in cols)
val_sql = ', '.join('${%s}$' % c for c in cols)
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (table, col_sql, val_sql)
return sql, {c: data[c] for c in cols}
def build_update(table, data, available, where):
"""构造 UPDATEWHERE 条件必须含 tenant_id由调用方保证"""
cols = [c for c in data if c in available]
if not cols:
raise RuntimeError('build_update(%s): 无可更新列' % (table,))
set_sql = ', '.join('`%s`=${s_%s}$' % (c, c) for c in cols)
conds, args = [], {}
for c in cols:
args['s_%s' % c] = data[c]
for k, v in (where or {}).items():
if k in available:
conds.append('`%s`=${w_%s}$' % (k, k))
args['w_%s' % k] = v
sql = 'UPDATE `%s` SET %s WHERE %s' % (
table, set_sql, ' AND '.join(conds) if conds else '1=1')
return sql, args
def build_where(where, available):
"""构造 WHERE 片段None 值转 IS NULL返回 (sql, params)。"""
conds, args = [], {}
for k, v in (where or {}).items():
if k not in available:
continue
if v is None or v == '':
continue
conds.append('`%s`=${%s}$' % (k, k))
args[k] = v
return (' AND '.join(conds) if conds else '1=1'), args
def invalidate_cache(table=None):
"""清列缓存DDL 变更后调用)。"""
global _COL_CACHE, _TABLE_CACHE
if table is None:
_COL_CACHE = {}
_TABLE_CACHE = {}
else:
_COL_CACHE = {k: v for k, v in _COL_CACHE.items() if k[1] != table}
_TABLE_CACHE = {k: v for k, v in _TABLE_CACHE.items() if k != table}

View File

@ -0,0 +1,239 @@
"""事件 → 证据 的映射与规范化规则M5a
来源``scense_runtime`` M11b 落库的 ``pbl_runtime_event``append-only按月 RANGE 分区
本文件是**纯函数层**不碰数据库因此可被 M5b 回放M6 评估单测直接复用
设计要点
- 事件类型 证据类型是**配置化常量表**不写死在 SQL / 不靠 LLM
未知事件类型默认降级为 ``observation``可观测证据不丢事件
- ``dedup_key`` = md5(tenant_id|source_event_id|evidence_type)32 位定长
``pbl_evidence.dedup_key`` VARCHAR(32) 及唯一索引
``uk_ev_dedup(tenant_id, source_event_id, evidence_type)`` 严格对齐
- 时间统一格式化为 ``YYYY-MM-DD HH:MM:SS`` 字符串避免 datetime/str 混用
导致 sqlor 占位符绑定失败
"""
import hashlib
from datetime import datetime, date
# ── 证据类型(与 appcodes_kv.pbl_evidence_type 对齐)─────────────────────
EV_ARTIFACT = 'artifact' # 产出物(作品/交付件/提交)
EV_ASSESSMENT = 'assessment' # 测评/作答证据
EV_PEER_REVIEW = 'peer_review' # 互评证据
EV_REFLECTION = 'reflection' # 反思/日志证据
EV_MILESTONE = 'milestone' # 里程碑/目标达成证据
EV_COLLABORATION = 'collaboration' # 协作证据
EV_RESOURCE = 'resource' # 资源使用证据
EV_OBSERVATION = 'observation' # 兜底:可观测行为证据
EVIDENCE_TYPES = (
EV_ARTIFACT, EV_ASSESSMENT, EV_PEER_REVIEW, EV_REFLECTION,
EV_MILESTONE, EV_COLLABORATION, EV_RESOURCE, EV_OBSERVATION,
)
EVIDENCE_TYPE_LABELS = {
EV_ARTIFACT: '产出物',
EV_ASSESSMENT: '测评作答',
EV_PEER_REVIEW: '同伴互评',
EV_REFLECTION: '反思日志',
EV_MILESTONE: '里程碑达成',
EV_COLLABORATION: '协作行为',
EV_RESOURCE: '资源使用',
EV_OBSERVATION: '一般观测',
}
# ── runtime_event.event_type → evidence_type ────────────────────────────
# 覆盖 M11b 广播的事件族;同义词一并列出,避免事件命名漂移导致证据漏采。
EVENT_TO_EVIDENCE = {
# 产出物
'artifact_created': EV_ARTIFACT,
'artifact_submitted': EV_ARTIFACT,
'artifact_updated': EV_ARTIFACT,
'artifact_version_created': EV_ARTIFACT,
'submission_created': EV_ARTIFACT,
'deliverable_submitted': EV_ARTIFACT,
'scene_saved': EV_ARTIFACT,
'world_snapshot_created': EV_ARTIFACT,
'script_compiled': EV_ARTIFACT,
# 测评作答
'quiz_answered': EV_ASSESSMENT,
'question_answered': EV_ASSESSMENT,
'assessment_submitted': EV_ASSESSMENT,
'test_submitted': EV_ASSESSMENT,
'task_checked': EV_ASSESSMENT,
'checkpoint_passed': EV_ASSESSMENT,
'checkpoint_failed': EV_ASSESSMENT,
# 互评
'peer_review_submitted': EV_PEER_REVIEW,
'peer_review_created': EV_PEER_REVIEW,
'review_commented': EV_PEER_REVIEW,
# 反思
'reflection_submitted': EV_REFLECTION,
'journal_written': EV_REFLECTION,
'self_assessment': EV_REFLECTION,
# 里程碑
'milestone_reached': EV_MILESTONE,
'goal_completed': EV_MILESTONE,
'objective_completed': EV_MILESTONE,
'task_completed': EV_MILESTONE,
'stage_completed': EV_MILESTONE,
'badge_awarded': EV_MILESTONE,
# 协作
'team_message_sent': EV_COLLABORATION,
'collaboration_joined': EV_COLLABORATION,
'member_mentioned': EV_COLLABORATION,
'task_assigned': EV_COLLABORATION,
'role_accepted': EV_COLLABORATION,
# 资源
'resource_viewed': EV_RESOURCE,
'resource_downloaded': EV_RESOURCE,
'material_opened': EV_RESOURCE,
'hint_used': EV_RESOURCE,
}
# 明确不落证据的事件(心跳/渲染/纯 UI 噪声),白名单外置于此便于审阅。
IGNORED_EVENTS = frozenset((
'heartbeat', 'ping', 'presence_update', 'ui_render',
'camera_moved', 'cursor_moved', 'audio_level',
))
# 事件行里可能承载「事件主键」的候选列M11b 表结构兼容层)
EVENT_ID_FIELDS = ('event_id', 'source_event_id', 'uuid', 'id')
# 事件行里可能承载「事件类型」的候选列
EVENT_TYPE_FIELDS = ('event_type', 'type', 'event_code', 'name')
# 事件行里可能承载「事件体」的候选列
EVENT_PAYLOAD_FIELDS = ('payload_json', 'event_payload', 'payload', 'data_json', 'detail_json')
# 事件行里可能承载「发生时间」的候选列
EVENT_TIME_FIELDS = ('occurred_at', 'event_time', 'created_at', 'ts')
# 学习者/会话/蓝图 的候选列
EVENT_LEARNER_FIELDS = ('learner_id', 'user_id', 'student_id', 'actor_id', 'member_id')
EVENT_SESSION_FIELDS = ('session_id', 'game_session_id', 'runtime_session_id')
EVENT_BLUEPRINT_FIELDS = ('blueprint_id', 'pbl_blueprint_id', 'bp_id')
_DT_FORMATS = (
'%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f',
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d/%H/%M/%S', '%Y-%m-%d',
)
def normalize_dt(value):
"""任意时间输入 → 'YYYY-MM-DD HH:MM:SS';无法解析返回 None。"""
if value is None or value == '':
return None
if isinstance(value, datetime):
return value.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(value, date):
return datetime(value.year, value.month, value.day).strftime('%Y-%m-%d %H:%M:%S')
text = str(value).strip()
if not text:
return None
if text.endswith('Z'):
text = text[:-1]
text = text.replace('T', ' ')
if '.' in text:
head = text.split('.', 1)[0]
else:
head = text
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
try:
return datetime.strptime(head, fmt).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
continue
for fmt in _DT_FORMATS:
try:
return datetime.strptime(text, fmt).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
continue
return None
def now_str():
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def first_present(row, candidates):
"""按候选列名顺序取第一个非空值(列名兼容层,不猜列)。"""
for key in candidates:
if key in row and row[key] not in (None, ''):
return row[key]
return None
def map_event_to_evidence_type(event_type, payload=None):
"""事件类型 → 证据类型;返回 None 表示该事件明确不采集。"""
if not event_type:
return EV_OBSERVATION
key = str(event_type).strip().lower()
if key in IGNORED_EVENTS:
return None
if key in EVENT_TO_EVIDENCE:
return EVENT_TO_EVIDENCE[key]
# 前缀/关键字启发(事件命名漂移兜底),未知仍降级 observation
rules = (
('submit', EV_ARTIFACT), ('artifact', EV_ARTIFACT), ('save', EV_ARTIFACT),
('answer', EV_ASSESSMENT), ('quiz', EV_ASSESSMENT), ('check', EV_ASSESSMENT),
('review', EV_PEER_REVIEW), ('reflect', EV_REFLECTION), ('journal', EV_REFLECTION),
('milestone', EV_MILESTONE), ('complete', EV_MILESTONE), ('badge', EV_MILESTONE),
('team', EV_COLLABORATION), ('collab', EV_COLLABORATION), ('mention', EV_COLLABORATION),
('resource', EV_RESOURCE), ('view', EV_RESOURCE), ('open', EV_RESOURCE),
)
for token, ev_type in rules:
if token in key:
return ev_type
return EV_OBSERVATION
def evidence_types_for_events():
"""反向索引:证据类型 → 事件类型列表(供按证据类型过滤采集)。"""
out = {t: [] for t in EVIDENCE_TYPES}
for ev, tp in EVENT_TO_EVIDENCE.items():
out.setdefault(tp, []).append(ev)
for tp in out:
out[tp].sort()
return out
def build_dedup_key(tenant_id, source_event_id, evidence_type):
"""32 位定长去重键,与 uk_ev_dedup 唯一索引语义一致。"""
raw = '%s|%s|%s' % (tenant_id, source_event_id, evidence_type)
return hashlib.md5(raw.encode('utf-8')).hexdigest()
def event_source_id(row):
"""从事件行提取 source_event_id字符串化最长 32"""
val = first_present(row, EVENT_ID_FIELDS)
if val is None:
return None
text = str(val).strip()
return text[:32] if text else None
def summarize_payload(row, event_type, evidence_type):
"""构造证据体 payload_json保留溯源信息 + 关键指标,不整表复制)。"""
from .db import json_loads
raw = first_present(row, EVENT_PAYLOAD_FIELDS)
payload = json_loads(raw, default={})
if not isinstance(payload, dict):
payload = {'raw': payload}
out = {
'source': 'pbl_runtime_event',
'event_type': str(event_type or ''),
'evidence_type': evidence_type,
'keys': {},
}
for k in ('artifact_id', 'task_id', 'blueprint_version_id', 'scene_id',
'score', 'result', 'status', 'duration_ms', 'attempt_no',
'title', 'name', 'content_ref', 'url', 'file_id'):
if k in row and row[k] not in (None, ''):
out['keys'][k] = row[k]
for k, v in payload.items():
if k in out['keys']:
continue
if isinstance(v, (str, int, float, bool)) or v is None:
text = str(v)
out['keys'][k] = text[:512] if isinstance(v, str) else v
body = {k: v for k, v in payload.items()
if isinstance(v, (dict, list))}
if body:
out['detail'] = body
return out

View File

@ -2,8 +2,14 @@
# -*- coding: utf-8 -*-
"""`load_pbl_evidence()` —— pbl_evidence 模块唯一挂载入口。
注册三处同步之 env.<契约名> = <契约名> 定义在 api.py 导出在 __init__.py
注册三处同步之 ``env.<契约名> = <契约名>``
定义在 api.py 导出在 __init__.py 本文件挂 ServerEnv
漏本文件注册 .dspy 调用 NameError
宿主应用apps/pbls ``init()`` 中按拓扑序调用本函数库名由宿主
``get_module_dbname('pbl_evidence')`` 决定本模块不硬编码
"""
from ahserver.serverenv import ServerEnv
from pbl_evidence.api import (
@ -13,19 +19,32 @@ from pbl_evidence.api import (
pbl_artifact_delete,
pbl_artifact_list,
pbl_evidence_collect,
pbl_evidence_collect_from_events,
pbl_evidence_list,
pbl_evidence_stats,
pbl_evidence_watermark,
)
from pbl_evidence.collector import collect_evidence_from_events, resolve_event_table
from pbl_evidence.evidence_map import EVIDENCE_TYPES, EVENT_TO_EVIDENCE
def load_pbl_evidence():
env = ServerEnv()
# ── 产出物 CRUD ──────────────────────────────────────────────
env.pbl_artifact_create = pbl_artifact_create
env.pbl_artifact_read = pbl_artifact_read
env.pbl_artifact_update = pbl_artifact_update
env.pbl_artifact_delete = pbl_artifact_delete
env.pbl_artifact_list = pbl_artifact_list
# ── 证据采集 / 查询M5a────────────────────────────────────
env.pbl_evidence_collect = pbl_evidence_collect
env.pbl_evidence_collect_from_events = pbl_evidence_collect_from_events
env.pbl_evidence_list = pbl_evidence_list
env.pbl_evidence_stats = pbl_evidence_stats
env.pbl_evidence_watermark = pbl_evidence_watermark
# ── 底层能力(供 M5b 回放 / M6 评估直接复用)──────────────────
env.pbl_collect_evidence_from_events = collect_evidence_from_events
env.pbl_resolve_event_table = resolve_event_table
env.pbl_evidence_types = list(EVIDENCE_TYPES)
env.pbl_event_to_evidence_map = dict(EVENT_TO_EVIDENCE)
return 'pbl_evidence'

View File

@ -0,0 +1,37 @@
# pbl_evidence/api/pbl_evidence_collect_from_events.dspy
# M5a 主能力:批量从 pbl_runtime_eventM11b 产物)采集学习证据,幂等落 pbl_evidence。
# 铁律dspy 内不 import、不 new ServerEnv()ServerEnv 注册的契约函数是直接全局;必须显式 return。
debug(f'pbl_evidence_collect_from_events.dspy: START params_kw={dict(params_kw)}')
try:
tenant = params_kw.get('tenant_id') or (await get_userorgid()) or '0'
def _as_list(val):
if val is None or val == '':
return None
if isinstance(val, (list, tuple)):
return [str(x).strip() for x in val if str(x).strip()]
return [x.strip() for x in str(val).split(',') if x.strip()]
res = await pbl_evidence_collect_from_events(
tenant_id=tenant,
since=params_kw.get('since'),
until=params_kw.get('until'),
session_id=params_kw.get('session_id'),
learner_id=params_kw.get('learner_id'),
blueprint_id=params_kw.get('blueprint_id'),
event_types=_as_list(params_kw.get('event_types')),
evidence_types=_as_list(params_kw.get('evidence_types')),
limit=params_kw.get('limit') or 500,
dry_run=params_kw.get('dry_run'),
update_existing=params_kw.get('update_existing'),
)
status = 'OK' if res.get('ok') else 'ERROR'
debug(f'pbl_evidence_collect_from_events.dspy: DONE status={status} msg={res.get("message")}')
return {'status': status,
'data': {k: v for k, v in res.items() if k != 'items'},
'items': res.get('items') or [],
'message': res.get('message') or ''}
except Exception as e:
error(f'pbl_evidence_collect_from_events.dspy: FAIL {format_exc()}')
return {'status': 'ERROR', 'data': None, 'message': str(e)}

View File

@ -0,0 +1,17 @@
# pbl_evidence/api/pbl_evidence_stats.dspy
# 证据类型分布统计(供 M6 评估 / 概览卡片。dspy 无 import、无 ServerEnv()、显式 return。
debug(f'pbl_evidence_stats.dspy: START params_kw={dict(params_kw)}')
try:
tenant = params_kw.get('tenant_id') or (await get_userorgid()) or '0'
res = await pbl_evidence_stats(
tenant_id=tenant,
learner_id=params_kw.get('learner_id'),
blueprint_id=params_kw.get('blueprint_id'),
)
status = 'OK' if res.get('ok') else 'ERROR'
debug(f'pbl_evidence_stats.dspy: DONE total={res.get("total")}')
return {'status': status, 'data': res, 'message': res.get('message') or ''}
except Exception as e:
error(f'pbl_evidence_stats.dspy: FAIL {format_exc()}')
return {'status': 'ERROR', 'data': None, 'message': str(e)}