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

This commit is contained in:
agent.develop 2026-09-22 11:58:20 +08:00
parent d198fb88cb
commit a026feb25a
3 changed files with 275 additions and 322 deletions

View File

@ -1,187 +1,56 @@
{ {
"tblname": "pbl_artifact", "tblname": "pbl_artifact",
"alias": "pbl_evidence", "alias": "pbl_evidence",
"browserfields": { "title": "学习产出物(artifact)",
"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": { "params": {
"browserfields": { "browserfields": {
"id": { "exclouded": [
"label": "ID", "content_json",
"list": true, "created_at",
"type": "number" "updated_at"
}, ],
"tenant_id": { "alters": {
"label": "租户ID", "artifact_type": {
"list": true, "uitype": "code",
"type": "text" "data": [
}, {"value": "document", "text": "文档报告"},
"artifact_uid": { {"value": "code", "text": "程序代码"},
"label": "产出UID", {"value": "model", "text": "模型/场景"},
"type": "text", {"value": "presentation", "text": "演示汇报"},
"list": true {"value": "poster", "text": "海报展板"},
}, {"value": "video", "text": "音视频作品"},
"session_id": { {"value": "dataset", "text": "数据/素材集"},
"label": "会话", {"value": "other", "text": "其他"}
"type": "number", ]
"list": true },
}, "status": {
"blueprint_id": { "uitype": "code",
"label": "蓝图", "data": [
"type": "number", {"value": "draft", "text": "草稿"},
"list": true {"value": "submitted", "text": "已提交"},
}, {"value": "reviewing", "text": "评审中"},
"team_id": { {"value": "returned", "text": "退回修改"},
"label": "团队", {"value": "graded", "text": "已评分"},
"type": "number", {"value": "archived", "text": "已归档"}
"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
} }
}, },
"editexclouded": [
"id",
"tenant_id",
"artifact_uid",
"version_no",
"created_at",
"updated_at"
],
"logined_userorgid": "tenant_id",
"editable": { "editable": {
"artifact_uid": { "new_data_url": "{{entire_url('../api/pbl_artifact_create.dspy')}}",
"label": "产出UID", "update_data_url": "{{entire_url('../api/pbl_artifact_update.dspy')}}",
"type": "text" "delete_data_url": "{{entire_url('../api/pbl_artifact_delete.dspy')}}"
},
"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"
}
}, },
"sortby": ["created_at desc"],
"order_by": "id DESC", "order_by": "id DESC",
"page_size": 20 "page_size": 20
} }

View File

@ -1,159 +1,54 @@
{ {
"tblname": "pbl_evidence", "tblname": "pbl_evidence",
"alias": "pbl_evidence", "alias": "pbl_evidence",
"browserfields": { "title": "学习证据(runtime_event 幂等采集)",
"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": { "params": {
"browserfields": { "browserfields": {
"id": { "exclouded": [
"label": "ID", "payload_json",
"list": true, "dedup_key",
"type": "number" "collect_batch_id",
}, "created_at",
"tenant_id": { "updated_at"
"label": "租户ID", ],
"list": true, "alters": {
"type": "text" "evidence_type": {
}, "uitype": "code",
"artifact_id": { "data": [
"label": "产出物", {"value": "artifact", "text": "产出物"},
"type": "number", {"value": "assessment", "text": "测评作答"},
"list": true {"value": "peer_review", "text": "同伴互评"},
}, {"value": "reflection", "text": "反思日志"},
"evidence_type": { {"value": "milestone", "text": "里程碑达成"},
"label": "证据类型", {"value": "collaboration", "text": "协作行为"},
"type": "select", {"value": "resource", "text": "资源使用"},
"list": true {"value": "observation", "text": "一般观测"}
}, ]
"source_event_id": { },
"label": "来源 runtime_event", "artifact_id": {
"type": "text", "uitype": "code",
"list": true "dataurl": "{{entire_url('../api/pbl_artifact_options.dspy')}}",
}, "datamethod": "GET",
"session_id": { "valueField": "value",
"label": "会话", "textField": "text"
"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
} }
}, },
"editexclouded": [
"id",
"tenant_id",
"dedup_key",
"collect_batch_id",
"created_at",
"updated_at"
],
"logined_userorgid": "tenant_id",
"editable": { "editable": {
"artifact_id": { "new_data_url": "{{entire_url('../api/pbl_evidence_collect.dspy')}}",
"label": "产出物", "update_data_url": "{{entire_url('../api/pbl_evidence_update.dspy')}}",
"type": "number" "delete_data_url": "{{entire_url('../api/pbl_evidence_delete.dspy')}}"
},
"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"
}
}, },
"sortby": ["occurred_at desc"],
"order_by": "id DESC", "order_by": "id DESC",
"page_size": 20 "page_size": 20
} }

189
pbl_evidence/crud_api.py Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_evidence.crud_api —— CRUD 定义(json/*.json)所需的自定义端点后端。
为什么单独成文件(不改 api.py)
------------------------------
api.py 已有 390 行契约面(产出物 CRUD + 证据幂等采集)。本文件只补
``json/evidence_pbl_evidence.json`` 里 ``params.editable`` 三个 URL 与
``browserfields.alters[].dataurl`` 所需的 4 个后端函数,保持 api.py 不动、
职责清晰:api.py = 业务契约,crud_api.py = CRUD 框架适配。
规范依据(crud-definition-spec / module-development-spec)
--------------------------------------------------------
1. ``editable.new_data_url/update_data_url/delete_data_url`` 必须有对应
``wwwroot/api/*.dspy``,且路径不含 ``wwwroot/``(json/ 视角用 ``../api/``)。
2. 下拉数据接口(``alters[].dataurl``)**必须返回裸 JSON 数组**
``[{value, text}, ...]``,异常/空数据返回 ``[]``,不许包 status/data。
3. 多租户铁律:所有读写 ``tenant_id`` 强制打头,缺失即 fail-closed;
更新/删除必须校验记录归属当前租户,防越权改他人证据。
4. 列名不猜:先 ``table_columns`` 发现真实列,再按交集拼 SQL。
5. 证据表的幂等唯一索引是 ``uk_ev_dedup(tenant_id, source_event_id, evidence_type)``;
手工编辑若改动这三元组,``dedup_key`` 必须同步重算,否则去重键与唯一索引漂移。
"""
from pbl_common.api import PblError, actor_id, json_dump, now_str, tenant_id
from pbl_evidence.db import (
build_update, build_where, json_loads, pick_columns, q_all, q_exec, q_one,
table_columns,
)
from pbl_evidence.evidence_map import (
EVIDENCE_TYPES, build_dedup_key, normalize_dt,
)
# 证据表允许人工修订的列(唯一索引三元组中的 tenant_id 永不开放)
EVIDENCE_EDITABLE_COLS = ('artifact_id', 'evidence_type', 'source_event_id',
'session_id', 'learner_id', 'blueprint_id',
'payload_json', 'occurred_at')
def _require_tenant():
tid = tenant_id()
if not tid:
raise PblError('PBL_E_TENANT_MISSING',
'tenant_id 缺失:pbl_evidence 所有读写强制带租户(fail-closed)')
return str(tid)
def _blank(value):
return value is None or (isinstance(value, str) and value.strip() == '')
# ══════════════════════════════════════════════════════════════════════
# 下拉数据:产出物选项(browserfields.alters.artifact_id.dataurl)
# ══════════════════════════════════════════════════════════════════════
async def pbl_artifact_options(**kw):
"""返回当前租户可选产出物 ``[{value,text}]``(裸数组,供 bricks UiCode 渲染)。
只取 id/title 两列(不 SELECT *,避免把 content_json 大字段拖进下拉查询)。
任何异常都降级为 ``[]``——下拉数据源报错会让整个列表页 500,宁可空列表。
"""
try:
tid = _require_tenant()
cols = await table_columns('pbl_artifact')
if not cols or 'id' not in cols:
return []
text_col = 'title' if 'title' in cols else ('artifact_uid' if 'artifact_uid' in cols else 'id')
where = {'tenant_id': tid}
for key in ('session_id', 'blueprint_id', 'status', 'creator_id'):
if not _blank(kw.get(key)) and key in cols:
where[key] = kw[key]
where_sql, args = build_where(where, cols)
limit = min(500, max(1, int(kw.get('limit') or 200)))
args2 = dict(args)
args2['limit'] = limit
rows = await q_all(
'SELECT `id`, `%s` AS label FROM `pbl_artifact` WHERE %s '
'ORDER BY `created_at` DESC LIMIT ${limit}$' % (text_col, where_sql), args2)
out = []
for r in rows:
val = r.get('id')
if _blank(val):
continue
label = r.get('label')
out.append({'value': str(val),
'text': ('%s' % label) if not _blank(label) else str(val)})
return out
except Exception:
return []
# ══════════════════════════════════════════════════════════════════════
# 证据人工修订 / 删除(editable.update_data_url / delete_data_url)
# ══════════════════════════════════════════════════════════════════════
async def pbl_evidence_update(**kw):
"""按 id 修订证据(强制租户校验;payload_json 自动包成合法 JSON 文本)。
与幂等采集的边界:批量采集走 ``pbl_evidence_collect_from_events``(服务端权威、
只增不改);本函数只服务人工纠错/补注,不开放 tenant_id,也不允许改 collect_batch_id
(采集批次号是审计线索)。改唯一索引三元组时同步重算 dedup_key。
"""
tid = _require_tenant()
eid = kw.get('id') or kw.get('evidence_id')
if _blank(eid):
raise PblError('PBL_E_PARAM', '缺少 id(证据主键)')
cols = await table_columns('pbl_evidence')
if not cols:
raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_evidence 表不存在,请先执行 models DDL')
where_sql, args = build_where({'tenant_id': tid, 'id': eid}, cols)
cur = await q_one('SELECT * FROM `pbl_evidence` WHERE %s LIMIT 1' % where_sql, args)
if not cur:
raise PblError('PBL_E_NOT_FOUND', '证据不存在或不属于当前租户')
upd = {'updated_at': now_str()}
for key in EVIDENCE_EDITABLE_COLS:
if key not in cols or _blank(kw.get(key)):
continue
if key == 'evidence_type':
etype = str(kw[key]).strip()
if etype not in EVIDENCE_TYPES:
raise PblError('PBL_E_PARAM',
'evidence_type 须为 %s' % '/'.join(sorted(EVIDENCE_TYPES)))
upd['evidence_type'] = etype
elif key == 'payload_json':
raw = kw[key]
if isinstance(raw, str):
parsed = json_loads(raw, default=None)
if parsed is None:
raise PblError('PBL_E_PARAM', 'payload_json 不是合法 JSON')
upd['payload_json'] = json_dump(parsed)
else:
upd['payload_json'] = json_dump(raw)
elif key == 'occurred_at':
stamp = normalize_dt(kw[key])
if not stamp:
raise PblError('PBL_E_PARAM', 'occurred_at 格式须为 YYYY-MM-DD HH:MM:SS')
upd['occurred_at'] = stamp
elif key == 'source_event_id':
upd['source_event_id'] = str(kw[key]).strip()[:32]
else:
upd[key] = str(kw[key])[:32]
# 唯一索引三元组变化 → dedup_key 同步重算(保持与 uk_ev_dedup 一致)
if 'dedup_key' in cols:
final_src = str(upd.get('source_event_id', cur.get('source_event_id') or ''))
final_type = str(upd.get('evidence_type', cur.get('evidence_type') or ''))
if final_src and final_type:
upd['dedup_key'] = build_dedup_key(tid, final_src, final_type)
upd = pick_columns(upd, cols)
sql, sql_args = build_update('pbl_evidence', upd, cols, {'tenant_id': tid, 'id': eid})
await q_exec(sql, sql_args)
return {'ok': True, 'status': 'OK', 'id': eid,
'updated_fields': sorted(upd.keys()),
'message': '证据已更新(%s 个字段)' % len(upd)}
async def pbl_evidence_delete(**kw):
"""按 id 删除证据(强制租户校验;支持 ids 批量,逗号分隔或数组)。"""
tid = _require_tenant()
raw_ids = kw.get('ids') if not _blank(kw.get('ids')) else (
kw.get('id') or kw.get('evidence_id'))
if _blank(raw_ids):
raise PblError('PBL_E_PARAM', '缺少 id / ids')
if isinstance(raw_ids, (list, tuple)):
id_list = [str(x).strip() for x in raw_ids if not _blank(x)]
else:
id_list = [x.strip() for x in str(raw_ids).split(',') if x.strip()]
if not id_list:
raise PblError('PBL_E_PARAM', 'id / ids 为空')
cols = await table_columns('pbl_evidence')
if not cols:
raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_evidence 表不存在,请先执行 models DDL')
deleted = 0
for eid in id_list[:500]:
where_sql, args = build_where({'tenant_id': tid, 'id': eid}, cols)
cur = await q_one('SELECT `id` FROM `pbl_evidence` WHERE %s LIMIT 1' % where_sql, args)
if not cur or _blank(cur.get('id')):
continue
await q_exec('DELETE FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ '
'AND `id`=${id}$', {'tenant_id': tid, 'id': eid})
deleted += 1
return {'ok': True, 'status': 'OK', 'deleted': deleted,
'requested': len(id_list), 'operator': actor_id() or '0',
'message': '已删除 %s 条证据' % deleted}