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",
"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
}
},
"title": "学习产出物artifact",
"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
"exclouded": [
"content_json",
"created_at",
"updated_at"
],
"alters": {
"artifact_type": {
"uitype": "code",
"data": [
{"value": "document", "text": "文档报告"},
{"value": "code", "text": "程序代码"},
{"value": "model", "text": "模型/场景"},
{"value": "presentation", "text": "演示汇报"},
{"value": "poster", "text": "海报展板"},
{"value": "video", "text": "音视频作品"},
{"value": "dataset", "text": "数据/素材集"},
{"value": "other", "text": "其他"}
]
},
"status": {
"uitype": "code",
"data": [
{"value": "draft", "text": "草稿"},
{"value": "submitted", "text": "已提交"},
{"value": "reviewing", "text": "评审中"},
{"value": "returned", "text": "退回修改"},
{"value": "graded", "text": "已评分"},
{"value": "archived", "text": "已归档"}
]
}
}
},
"editexclouded": [
"id",
"tenant_id",
"artifact_uid",
"version_no",
"created_at",
"updated_at"
],
"logined_userorgid": "tenant_id",
"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"
}
"new_data_url": "{{entire_url('../api/pbl_artifact_create.dspy')}}",
"update_data_url": "{{entire_url('../api/pbl_artifact_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/pbl_artifact_delete.dspy')}}"
},
"sortby": ["created_at desc"],
"order_by": "id DESC",
"page_size": 20
}

View File

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