117 lines
10 KiB
Markdown
117 lines
10 KiB
Markdown
---
|
||
name: pbl_evidence
|
||
description: 产出物与证据幂等采集模块(M5a/M5b)——pbl_artifact/pbl_evidence 两表 CRUD、事件→证据幂等采集、证据列表/统计/水位查询。改本模块前先读完「契约接口」「陷阱」两节。
|
||
---
|
||
|
||
# pbl_evidence 模块技能
|
||
|
||
## 定位
|
||
产出物(artifact)管理与学习证据(evidence)**幂等采集**。M5a 提供产出物 CRUD + 单事件/批量事件落证据;
|
||
M5b(回放/增量续采)直接复用本模块的采集器与水位接口。
|
||
|
||
## 挂载
|
||
```python
|
||
from pbl_evidence.init import load_pbl_evidence
|
||
load_pbl_evidence() # 宿主 apps/pbls 的 app/pbls.py init() 中按拓扑序调用
|
||
```
|
||
模块是 Python 包(**无 app.py / 无端口 / 无独立部署单元**),前端仅 `wwwroot/` 下 .ui/.dspy 静态资源。
|
||
|
||
## 数据表(2 张)
|
||
- `pbl_artifact`:学生产出物(版本内联 `version_no`,唯一索引 `uk_art_uid(tenant_id, artifact_uid)`)
|
||
- `pbl_evidence`:学习证据(唯一索引 `uk_ev_dedup(tenant_id, source_event_id, evidence_type)` 防重)
|
||
|
||
表定义:`models/{table}.json`(四段式 summary/fields/indexes/codes);CRUD:`json/pbl_artifact.json` +
|
||
`json/pbl_evidence.json`(文件名 = tblname;**不要**给两张不同的表共用同一 alias,
|
||
xls2ui 会生成到同一目录互相覆盖)。
|
||
编码种子:`init/data.json`(Format B,3 组 appcodes,见下「编码字典」)。
|
||
|
||
## 契约接口(13 个,与 `pbl_evidence/init.py` 的 `load_pbl_evidence()` env 注册一一对应)
|
||
|
||
| # | 契约函数 | .dspy 路径(`/pbl_evidence/api/...`) | 说明 |
|
||
|---|----------|--------------------------------------|------|
|
||
| 1 | `pbl_artifact_create` | `pbl_artifact_create.dspy` | 新建产出物,自动补 `tenant_id`/`artifact_uid`/`version_no=1`/`status=draft`/`created_at` |
|
||
| 2 | `pbl_artifact_read` | `pbl_artifact_read.dspy` | 按 id 读,强制租户过滤 |
|
||
| 3 | `pbl_artifact_update` | `pbl_artifact_update.dspy` | 白名单列更新,禁改 `tenant_id`/`id` |
|
||
| 4 | `pbl_artifact_delete` | `pbl_artifact_delete.dspy` | 带归属校验的删除 |
|
||
| 5 | `pbl_artifact_list` | `pbl_artifact_list.dspy` | 分页 + 会话/蓝图/作者/类型/状态过滤 |
|
||
| 6 | `pbl_evidence_collect` | `pbl_evidence_collect.dspy` | 单事件 → 一条证据;重复调用返回 `deduped=True` |
|
||
| 7 | `pbl_evidence_collect_from_events` | `pbl_evidence_collect_from_events.dspy` | **M5a 主能力**:扫 `pbl_runtime_event` → 映射 → 幂等落库,返回 scanned/created/skipped/updated/ignored/failed + watermark |
|
||
| 8 | `pbl_evidence_list` | `pbl_evidence_list.dspy` | 学习者/产出物/会话/蓝图/类型/时间窗过滤 + 分页 |
|
||
| 9 | `pbl_evidence_stats` | `pbl_evidence_stats.dspy` | 证据类型分布统计,供 M6 评估与概览页 |
|
||
| 10 | `pbl_evidence_watermark` | **无独立 .dspy**(见下) | 返回某会话/蓝图已采集到的时间水位,供 M5b/cron 增量续采 |
|
||
| 11 | `pbl_artifact_options` | `pbl_artifact_options.dspy` | **CRUD 适配**(`crud_api.py`):产出物下拉数据源,返回**裸数组** `[{value,text}]`,异常降级 `[]`;供 `json/pbl_evidence.json` 的 `alters.artifact_id.dataurl` |
|
||
| 12 | `pbl_evidence_update` | `pbl_evidence_update.dspy` | **CRUD 适配**(`crud_api.py`):人工修订证据,强制 `tenant_id` 归属校验;改唯一索引三元组时同步重算 `dedup_key`;供 `editable.update_data_url`。**dspy 返回 bricks Message widget JSON 字符串**(成功 type=success / 异常 type=error),不是裸数据 |
|
||
| 13 | `pbl_evidence_delete` | `pbl_evidence_delete.dspy` | **CRUD 适配**(`crud_api.py`):按 id/ids 删除证据,逐条校验归属;供 `editable.delete_data_url`。**dspy 返回 bricks Message widget JSON 字符串**(成功 type=success / 异常 type=error),不是裸数据 |
|
||
|
||
**watermark 的调用方式(第 10 条契约刻意不生成 .dspy)**:
|
||
- 内部调用:宿主/cron/M5b 通过 `ServerEnv().pbl_evidence_watermark(...)` 直调;
|
||
- 备选(若需走 HTTP):`pbl_evidence_stats.dspy` 返回值中**扩展** `watermark` 字段(stats 内嵌),
|
||
当前实现 `collector.evidence_stats` 只返回 `total/by_type`,**尚未内嵌** —— 需要时改 stats 的
|
||
返回聚合(`res['watermark'] = await _watermark(tid)`)即可,不必新增 .dspy;
|
||
- 若将来需要前端直接轮询,再新增 `wwwroot/api/pbl_evidence_watermark.dspy`,并**同步登记**
|
||
`scripts/load_path.py` + 中央 `apps/pbls/scripts/load_path.py`(双层 RBAC),否则 403。
|
||
|
||
> 第 11~13 条是 **CRUD 框架适配契约**(定义在 `pbl_evidence/crud_api.py`,不改 `api.py`),
|
||
> 同样走「三处同步」:`crud_api.py` 定义 → `__init__.py` 导出 → `init.py` `env.xxx = xxx`,
|
||
> 并各自有 `wwwroot/api/*.dspy` 薄包装与 `scripts/load_path.py` 登记。漏任一处 = dspy 调用
|
||
> NameError / 端点 404 / 登录后 403(QC 硬门禁三条,实测都踩过)。
|
||
> `json/pbl_artifact.json` 的 new/update/delete 指向既有 `pbl_artifact_{create,update,delete}.dspy`
|
||
> (委托 `api.py` 第 1/3/4 条契约),**不经过 crud_api**。
|
||
|
||
> 除上述 13 个对外契约外,`init.py` 还注册了 4 个**内部能力**(供 M5b 回放 / M6 评估复用,不算对外契约、
|
||
> 无 .dspy):`pbl_collect_evidence_from_events`、`pbl_resolve_event_table`、`pbl_evidence_types`、
|
||
> `pbl_event_to_evidence_map`。核对口径:
|
||
> `api.py` 契约定义 10 + `crud_api.py` 适配定义 3 == `__init__.py` 契约导出 13
|
||
> == `init.py` 契约 env 注册 13 == `wwwroot/api/*.dspy` 12(watermark 无 .dspy)。
|
||
> 自查命令:`grep -rn 'pbl_evidence_update' pbl_evidence/ --include='*.py'`
|
||
> 必须命中 定义(crud_api.py) / __init__.py import / __all__ / init.py import / env 注册。
|
||
|
||
> **editable 提交端点返回形态(QC #15 收口,勿回退)**:
|
||
> `pbl_evidence_update.dspy` / `pbl_evidence_delete.dspy` 是 `json/pbl_evidence.json` 的
|
||
> `update_data_url` / `delete_data_url` 目标,即 DataViewer **editable 提交端点**,按
|
||
> dspy-file-implementation-spec 必须返回
|
||
> `{"widgettype":"Message","options":{"title","message","type"}}` 的 **JSON 字符串**
|
||
> (`json.dumps(..., ensure_ascii=False)`),成功 `type='success'`、异常 `type='error'`。
|
||
> 后端 `crud_api.py` 仍返回结构化 dict(`ok/status/id/message`)供程序化调用,
|
||
> **由 dspy 负责转成 Message widget**——这是 Pitfall 25「直接 return 被委托 helper」之外的
|
||
> 自行组装形态,故必须走 Message,不得返回裸数据。
|
||
> `pbl_artifact_options.dspy` 是**下拉数据源**(非 editable 端点),按 crud-definition-spec
|
||
> 继续返回裸数组 `[{value,text}]`,异常降级 `[]`,**不要**改成 Message。
|
||
> `json/pbl_artifact.json` 的 new/update/delete 走 `api.py` 契约端点(框架生成语义),
|
||
> 与上面两者形态不同属**有意区分**(editable 提交 vs 契约直调),非目录内格式分裂。
|
||
|
||
## 编码字典(init/data.json,Format B)
|
||
| parentid(≤22 字符) | 含义 | 子项 |
|
||
|---|---|---|
|
||
| `pbl_artifact_type` | 产出物类型 | document/code/model/presentation/poster/video/dataset/other(8) |
|
||
| `pbl_artifact_status` | 产出物状态 | draft/submitted/reviewing/returned/graded/archived(6) |
|
||
| `pbl_evidence_type` | 证据类型 | 取自 `evidence_map.EVIDENCE_TYPES` 8 类:artifact/assessment/peer_review/reflection/milestone/collaboration/resource/observation |
|
||
|
||
`models/*.json` 的 `codes[].cond` 一律 `parentid='...'`(**绝不用 `id=`**),且三组 parentid 与
|
||
`models/pbl_artifact.json`、`models/pbl_evidence.json` 中的 cond 完全一致。
|
||
|
||
## 陷阱
|
||
- 库名一律 `get_module_dbname('pbl_evidence')`(.dspy 直接调用;.py 用 `ServerEnv().get_module_dbname(...)`),
|
||
**禁止硬编码 DBNAME**;取不到库名直接抛 `PblError`(fail-closed),不退化成查错库。
|
||
- sqlor 只有 `C/U/D/R/I/sqlExe`;查询统一走 `pbl_common.api` 的 `q_all/q_one`(已适配 sqlor 上下文)。
|
||
`sor.I` 只接 1 个参数(表名由上下文决定),`sor.I('t', data)` 会 TypeError。
|
||
- 所有读写强制带 `tenant_id`(`pbl_common.api.tenant_id()`),缺失即 fail-closed 报错。
|
||
- `dedup_key` = md5(`tenant_id|source_event_id|evidence_type`),32 位定长,必须与 `uk_ev_dedup` 语义对齐;
|
||
改唯一索引必须同步改 `evidence_map.build_dedup_key`。
|
||
- 时间统一格式化为 `YYYY-MM-DD HH:MM:SS` 字符串(`normalize_dt`),避免 datetime/str 混用导致
|
||
sqlor 占位符绑定失败。
|
||
- 未知事件类型降级为 `observation`(不丢事件);`IGNORED_EVENTS` 白名单外置在 `evidence_map.py`,
|
||
新增噪声事件改这里,别在 SQL 里写死过滤。
|
||
- **函数注册三处同步**:① `api.py` 定义 → ② `__init__.py` 导出 → ③ `init.py` `env.<name>=<name>`;
|
||
新增对外路径再加第 ④ 处 `scripts/load_path.py`(+ 中央宿主清单,双层 RBAC,禁通配符)。
|
||
漏 ② → import 期 ImportError;漏 ③ → .dspy NameError;漏 ④ → 403。
|
||
- 批量采集依赖 M11b 的 `pbl_runtime_event`;该表不存在时按设计抛
|
||
`CollectError → PBL_E_DB_UNAVAILABLE`(本地环境未联调,勿当成代码缺陷)。
|
||
- **dspy 内 dict 字面量禁写 f-string(QC #14)**:`{'message': f'...{e}'}` 的花括号嵌在 dict 字面量里会被 ahserver 的 `exec()` 误判提前闭合外层 dict,触发 `SyntaxError: '{' was never closed`,**整文件编译失败**(不只是异常路径失效)。一律写字符串拼接 `'证据更新失败:' + str(e)`。顶部 `debug(f'...')` 前缀日志是推荐写法,不受此限。自查:`grep -n "f'" wwwroot/api/*.dspy | grep -v debug` → 无输出即合格。
|
||
- `wwwroot/{alias}/`(xls2ui 生成物)是只读构建产物,改逻辑改 `json/*.json` + `models/*.json` 后重跑 build。
|
||
|
||
## 依赖
|
||
- 直接:`sqlor`(见 `pyproject.toml`,仅此一项)
|
||
- 基础包(宿主 build.sh 安装,不写进 pyproject):`apppublic`、`ahserver`、`appbase`、`rbac`
|
||
- 兄弟模块:`pbl_common`(租户上下文/错误码/时间序列化/查询封装)
|