2026-09-22 13:21:06 +08:00

117 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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`(租户上下文/错误码/时间序列化/查询封装)