--- 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.=`; 新增对外路径再加第 ④ 处 `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`(租户上下文/错误码/时间序列化/查询封装)