feat(pbl_evidence): 初始提交 - pbls 项目模块代码入库
This commit is contained in:
parent
8fb18b97c6
commit
3caaa5882a
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
188
json/evidence_pbl_artifact.json
Normal file
188
json/evidence_pbl_artifact.json
Normal file
@ -0,0 +1,188 @@
|
||||
{
|
||||
"tblname": "pbl_artifact",
|
||||
"alias": "pbl_evidence",
|
||||
"browserfields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
"list": true,
|
||||
"type": "text"
|
||||
},
|
||||
"artifact_uid": {
|
||||
"label": "产出UID",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"team_id": {
|
||||
"label": "团队",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"creator_id": {
|
||||
"label": "作者",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"title": {
|
||||
"label": "标题",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"artifact_type": {
|
||||
"label": "类型",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"content_json": {
|
||||
"label": "内容",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"version_no": {
|
||||
"label": "版本",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"status": {
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"submitted_at": {
|
||||
"label": "提交时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
"list": true,
|
||||
"type": "text"
|
||||
},
|
||||
"artifact_uid": {
|
||||
"label": "产出UID",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"team_id": {
|
||||
"label": "团队",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"creator_id": {
|
||||
"label": "作者",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"title": {
|
||||
"label": "标题",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"artifact_type": {
|
||||
"label": "类型",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"content_json": {
|
||||
"label": "内容",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"version_no": {
|
||||
"label": "版本",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"status": {
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"submitted_at": {
|
||||
"label": "提交时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"editable": {
|
||||
"artifact_uid": {
|
||||
"label": "产出UID",
|
||||
"type": "text"
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number"
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number"
|
||||
},
|
||||
"team_id": {
|
||||
"label": "团队",
|
||||
"type": "number"
|
||||
},
|
||||
"creator_id": {
|
||||
"label": "作者",
|
||||
"type": "text"
|
||||
},
|
||||
"title": {
|
||||
"label": "标题",
|
||||
"type": "select"
|
||||
},
|
||||
"artifact_type": {
|
||||
"label": "类型",
|
||||
"type": "select"
|
||||
},
|
||||
"content_json": {
|
||||
"label": "内容",
|
||||
"type": "text"
|
||||
},
|
||||
"version_no": {
|
||||
"label": "版本",
|
||||
"type": "number"
|
||||
},
|
||||
"status": {
|
||||
"label": "状态",
|
||||
"type": "select"
|
||||
},
|
||||
"submitted_at": {
|
||||
"label": "提交时间",
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"order_by": "id DESC",
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
160
json/evidence_pbl_evidence.json
Normal file
160
json/evidence_pbl_evidence.json
Normal file
@ -0,0 +1,160 @@
|
||||
{
|
||||
"tblname": "pbl_evidence",
|
||||
"alias": "pbl_evidence",
|
||||
"browserfields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
"list": true,
|
||||
"type": "text"
|
||||
},
|
||||
"artifact_id": {
|
||||
"label": "产出物",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"evidence_type": {
|
||||
"label": "证据类型",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"source_event_id": {
|
||||
"label": "来源 runtime_event",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"learner_id": {
|
||||
"label": "学习者",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"payload_json": {
|
||||
"label": "证据体",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"occurred_at": {
|
||||
"label": "发生时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"dedup_key": {
|
||||
"label": "去重键",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
"list": true,
|
||||
"type": "text"
|
||||
},
|
||||
"artifact_id": {
|
||||
"label": "产出物",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"evidence_type": {
|
||||
"label": "证据类型",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"source_event_id": {
|
||||
"label": "来源 runtime_event",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"learner_id": {
|
||||
"label": "学习者",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"payload_json": {
|
||||
"label": "证据体",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"occurred_at": {
|
||||
"label": "发生时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"dedup_key": {
|
||||
"label": "去重键",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"editable": {
|
||||
"artifact_id": {
|
||||
"label": "产出物",
|
||||
"type": "number"
|
||||
},
|
||||
"evidence_type": {
|
||||
"label": "证据类型",
|
||||
"type": "select"
|
||||
},
|
||||
"source_event_id": {
|
||||
"label": "来源 runtime_event",
|
||||
"type": "text"
|
||||
},
|
||||
"session_id": {
|
||||
"label": "会话",
|
||||
"type": "number"
|
||||
},
|
||||
"learner_id": {
|
||||
"label": "学习者",
|
||||
"type": "text"
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "蓝图",
|
||||
"type": "number"
|
||||
},
|
||||
"payload_json": {
|
||||
"label": "证据体",
|
||||
"type": "text"
|
||||
},
|
||||
"occurred_at": {
|
||||
"label": "发生时间",
|
||||
"type": "text"
|
||||
},
|
||||
"dedup_key": {
|
||||
"label": "去重键",
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"order_by": "id DESC",
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
144
models/pbl_artifact.json
Normal file
144
models/pbl_artifact.json
Normal file
@ -0,0 +1,144 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_artifact",
|
||||
"comment": "学生产出物(版本内联 version_no)",
|
||||
"module": "pbl_evidence",
|
||||
"engine": "mariadb",
|
||||
"charset": "utf8mb4",
|
||||
"tenant_scoped": true
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"codes": [
|
||||
{
|
||||
"field": "artifact_type",
|
||||
"appcode": "pbl_artifact_type"
|
||||
}
|
||||
]
|
||||
}
|
||||
132
models/pbl_evidence.json
Normal file
132
models/pbl_evidence.json
Normal file
@ -0,0 +1,132 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_evidence",
|
||||
"comment": "学习证据(幂等采集,唯一索引防重)",
|
||||
"module": "pbl_evidence",
|
||||
"engine": "mariadb",
|
||||
"charset": "utf8mb4",
|
||||
"tenant_scoped": true
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"codes": [
|
||||
{
|
||||
"field": "evidence_type",
|
||||
"appcode": "pbl_evidence_type"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
pbl_evidence/__init__.py
Normal file
29
pbl_evidence/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_evidence —— 产出物与证据幂等采集(M5a/M5b)
|
||||
|
||||
注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。
|
||||
"""
|
||||
from pbl_evidence.init import load_pbl_evidence
|
||||
from pbl_evidence.api import (
|
||||
pbl_artifact_create,
|
||||
pbl_artifact_read,
|
||||
pbl_artifact_update,
|
||||
pbl_artifact_delete,
|
||||
pbl_artifact_list,
|
||||
pbl_evidence_collect,
|
||||
pbl_evidence_list,
|
||||
|
||||
)
|
||||
|
||||
__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',
|
||||
|
||||
]
|
||||
129
pbl_evidence/api.py
Normal file
129
pbl_evidence/api.py
Normal file
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_evidence.api —— 产出物 + 证据幂等采集(第18/19章,M5a/M5b)。
|
||||
|
||||
幂等硬线:`pbl_evidence` 唯一索引 (tenant_id, source_event_id, evidence_type)。
|
||||
重复采集(重放/双通道:广播 + 3s 轮询兜底同事件)→ 命中已存在行,返回 deduped=True,
|
||||
不产生第二条证据,也不报错(学生侧无感)。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec,
|
||||
sql_rows, tenant_id)
|
||||
|
||||
EVIDENCE_TYPES = ['artifact', 'process_event', 'reflection', 'feedback']
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
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'])
|
||||
|
||||
|
||||
async def _tid():
|
||||
return await tenant_id()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def pbl_artifact_read(**kw):
|
||||
return await ART['read'](**kw)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def pbl_artifact_list(**kw):
|
||||
return await ART['list'](**kw)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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')
|
||||
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}
|
||||
31
pbl_evidence/init.py
Normal file
31
pbl_evidence/init.py
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`load_pbl_evidence()` —— pbl_evidence 模块唯一挂载入口。
|
||||
|
||||
注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。
|
||||
"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
|
||||
from pbl_evidence.api import (
|
||||
pbl_artifact_create,
|
||||
pbl_artifact_read,
|
||||
pbl_artifact_update,
|
||||
pbl_artifact_delete,
|
||||
pbl_artifact_list,
|
||||
pbl_evidence_collect,
|
||||
pbl_evidence_list,
|
||||
|
||||
)
|
||||
|
||||
|
||||
def load_pbl_evidence():
|
||||
env = ServerEnv()
|
||||
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
|
||||
env.pbl_evidence_collect = pbl_evidence_collect
|
||||
env.pbl_evidence_list = pbl_evidence_list
|
||||
|
||||
return 'pbl_evidence'
|
||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "pbl_evidence"
|
||||
version = "0.1.0"
|
||||
description = "产出物与证据幂等采集(M5a/M5b)"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["pbl_evidence"]
|
||||
46
scripts/load_path.py
Normal file
46
scripts/load_path.py
Normal file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_evidence RBAC 路径注册(硬门禁 6.6 / QC #11)。
|
||||
|
||||
约定:
|
||||
- 路径 = 模块自动路由 `/pbl_evidence/api/<契约>.dspy`,不带端口、不带 /wss 前缀;
|
||||
- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin);
|
||||
- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
MODULE = 'pbl_evidence'
|
||||
|
||||
# (path, role)
|
||||
PATHS = [
|
||||
('/pbl_evidence/api/pbl_artifact_create.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_artifact_read.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_artifact_update.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_artifact_delete.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_artifact_list.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_evidence_collect.dspy', 'logined'),
|
||||
('/pbl_evidence/api/pbl_evidence_list.dspy', 'logined'),
|
||||
|
||||
]
|
||||
|
||||
|
||||
def register():
|
||||
tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py')
|
||||
done, missing = 0, []
|
||||
for path, role in PATHS:
|
||||
if subprocess.call([sys.executable if os.environ.get('PY') else 'python3',
|
||||
tool, role, path],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
|
||||
done += 1
|
||||
else:
|
||||
missing.append((path, role))
|
||||
print('[%s] rbac paths: total=%%d ok=%%d pending=%%d' %% (len(PATHS), done, len(missing)))
|
||||
for path, role in missing:
|
||||
print(' PENDING %%-12s %%s' %% (role, path))
|
||||
return len(missing) == 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(0 if register() else 1)
|
||||
26
skill/SKILL.md
Normal file
26
skill/SKILL.md
Normal file
@ -0,0 +1,26 @@
|
||||
# pbl_evidence 模块技能(自动生成骨架 + 人工补充)
|
||||
|
||||
## 定位
|
||||
产出物与证据幂等采集(M5a/M5b)
|
||||
|
||||
## 挂载
|
||||
`from pbl_evidence.init import load_pbl_evidence` → `load_pbl_evidence()`(应用 app/pbls.py init() 中按序调用)
|
||||
|
||||
## 数据表(2 张)
|
||||
- `pbl_artifact`:学生产出物(版本内联 version_no)
|
||||
- `pbl_evidence`:学习证据(幂等采集,唯一索引防重)
|
||||
|
||||
## 契约接口(7 个,路径 `/pbl_evidence/api/<name>.dspy`)
|
||||
- `pbl_artifact_create`
|
||||
- `pbl_artifact_read`
|
||||
- `pbl_artifact_update`
|
||||
- `pbl_artifact_delete`
|
||||
- `pbl_artifact_list`
|
||||
- `pbl_evidence_collect`
|
||||
- `pbl_evidence_list`
|
||||
|
||||
## 陷阱
|
||||
- 库名一律 `ServerEnv().get_module_dbname('pbl_evidence')`,禁止硬编码 DBNAME。
|
||||
- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 pbl_common.api 的 q_all/q_one(已适配)。
|
||||
- 所有读写强制带 `tenant_id`(pbl_common.api.tenant_id()),缺失即 fail-closed 报错。
|
||||
- 新增契约需同步三处:api.py 定义 + __init__.py 导出 + init.py env 注册 + scripts/load_path.py 路径。
|
||||
41
sql/pbl_evidence.sql
Normal file
41
sql/pbl_evidence.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- pbl_evidence 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源)
|
||||
CREATE TABLE IF NOT EXISTS `pbl_artifact` (
|
||||
`tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头),
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键,
|
||||
`artifact_uid` VARCHAR(32) NOT NULL COMMENT "产出UID",
|
||||
`session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话",
|
||||
`blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图",
|
||||
`team_id` BIGINT UNSIGNED NOT NULL COMMENT "团队",
|
||||
`creator_id` VARCHAR(32) NOT NULL COMMENT "作者",
|
||||
`title` VARCHAR(128) NOT NULL COMMENT "标题",
|
||||
`artifact_type` VARCHAR(64) NOT NULL COMMENT "类型",
|
||||
`content_json` LONGTEXT NULL COMMENT "内容",
|
||||
`version_no` INT NOT NULL DEFAULT 0 COMMENT "版本",
|
||||
`status` VARCHAR(64) NOT NULL COMMENT "状态",
|
||||
`submitted_at` DATETIME NULL NULL COMMENT "提交时间",
|
||||
`created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)",
|
||||
`updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)",
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_art_uid` (`tenant_id`, `artifact_uid`),
|
||||
KEY `idx_art_session` (`tenant_id`, `session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="学生产出物(版本内联 version_no)";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pbl_evidence` (
|
||||
`tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头),
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键,
|
||||
`artifact_id` BIGINT UNSIGNED NOT NULL COMMENT "产出物",
|
||||
`evidence_type` VARCHAR(64) NOT NULL COMMENT "证据类型",
|
||||
`source_event_id` VARCHAR(32) NOT NULL COMMENT "来源 runtime_event",
|
||||
`session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话",
|
||||
`learner_id` VARCHAR(32) NOT NULL COMMENT "学习者",
|
||||
`blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图",
|
||||
`payload_json` LONGTEXT NULL COMMENT "证据体",
|
||||
`occurred_at` DATETIME NOT NULL COMMENT "发生时间",
|
||||
`dedup_key` VARCHAR(32) NOT NULL COMMENT "去重键",
|
||||
`created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)",
|
||||
`updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)",
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ev_dedup` (`tenant_id`, `source_event_id`, `evidence_type`),
|
||||
KEY `idx_ev_learner` (`tenant_id`, `learner_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="学习证据(幂等采集,唯一索引防重)";
|
||||
|
||||
4
wwwroot/api/pbl_artifact_create.dspy
Normal file
4
wwwroot/api/pbl_artifact_create.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_artifact_create.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_artifact_create.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_artifact_create(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_artifact_delete.dspy
Normal file
4
wwwroot/api/pbl_artifact_delete.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_artifact_delete.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_artifact_delete.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_artifact_delete(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_artifact_list.dspy
Normal file
4
wwwroot/api/pbl_artifact_list.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_artifact_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_artifact_list.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_artifact_list(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_artifact_read.dspy
Normal file
4
wwwroot/api/pbl_artifact_read.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_artifact_read.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_artifact_read.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_artifact_read(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_artifact_update.dspy
Normal file
4
wwwroot/api/pbl_artifact_update.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_artifact_update.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_artifact_update.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_artifact_update(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_evidence_collect.dspy
Normal file
4
wwwroot/api/pbl_evidence_collect.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_evidence_collect.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_evidence_collect.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_evidence_collect(**params_kw)
|
||||
return data
|
||||
4
wwwroot/api/pbl_evidence_list.dspy
Normal file
4
wwwroot/api/pbl_evidence_list.dspy
Normal file
@ -0,0 +1,4 @@
|
||||
# pbl_evidence/api/pbl_evidence_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
||||
debug('pbl_evidence/api/pbl_evidence_list.dspy: START params_kw={dict(params_kw)}')
|
||||
data = await pbl_evidence_list(**params_kw)
|
||||
return data
|
||||
231
wwwroot/index.ui
Normal file
231
wwwroot/index.ui
Normal file
@ -0,0 +1,231 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"padding": "20px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "产出物与证据幂等采集(M5a/M5b)",
|
||||
"fontSize": "24px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_artifact_create.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_artifact_create"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_artifact_read.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_artifact_read"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_artifact_update.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_artifact_update"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_artifact_delete.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_artifact_delete"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_artifact_list.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_artifact_list"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_evidence_collect.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_evidence_collect"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_evidence_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/pbl_evidence_list.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "pbl_evidence_list"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "pbl_evidence_content",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"flex": "1",
|
||||
"marginTop": "20px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user