pbl_evidence/README.md
2026-09-22 13:21:06 +08:00

257 lines
16 KiB
Markdown
Raw 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.

# pbl_evidence
PBL项目式学习的**产出物与学习证据**模块。它做两件事:维护学生产出物(`pbl_artifact`
以及把运行时产生的学习事件**幂等地**采集成学习证据(`pbl_evidence`)。
- 模块类型业务模块Python 包),**不是**独立部署单元 —— 没有 app.py、没有自己的端口、
没有 Dockerfile由宿主应用`apps/pbls`)调用 `load_pbl_evidence()` 挂载后运行。
- 交付里程碑M5a事件 → 证据 幂等采集 + 产出物 CRUD、M5b回放 / 增量游标 / 统计)。
- 下游M6 评估Rubric 加权打分)以本模块的证据表为唯一事实源。
## Features能力清单
| 能力 | 契约函数 | DSPY 路径 | 说明 |
|------|----------|-----------|------|
| 产出物新增 | `pbl_artifact_create` | `/pbl_evidence/api/pbl_artifact_create.dspy` | 自动补 `tenant_id` / `artifact_uid` / `created_at` |
| 产出物读取 | `pbl_artifact_read` | `/pbl_evidence/api/pbl_artifact_read.dspy` | 按 id 读,强制租户过滤 |
| 产出物更新 | `pbl_artifact_update` | `/pbl_evidence/api/pbl_artifact_update.dspy` | 白名单列更新,禁改 `tenant_id` |
| 产出物删除 | `pbl_artifact_delete` | `/pbl_evidence/api/pbl_artifact_delete.dspy` | 带归属校验 |
| 产出物列表 | `pbl_artifact_list` | `/pbl_evidence/api/pbl_artifact_list.dspy` | 分页 + 会话/蓝图/作者过滤 |
| 单事件落证据 | `pbl_evidence_collect` | `/pbl_evidence/api/pbl_evidence_collect.dspy` | 一条事件 → 一条证据,重复调用返回 `deduped=True` |
| **批量采集M5a 主能力)** | `pbl_evidence_collect_from_events` | `/pbl_evidence/api/pbl_evidence_collect_from_events.dspy` | 扫 `pbl_runtime_event` → 映射 → 幂等落库,返回 scanned/created/skipped/updated/ignored/failed + watermark |
| 证据列表 | `pbl_evidence_list` | `/pbl_evidence/api/pbl_evidence_list.dspy` | 学习者/产出物/会话/蓝图/类型/时间窗过滤 + 分页 |
| 证据统计 | `pbl_evidence_stats` | `/pbl_evidence/api/pbl_evidence_stats.dspy` | 证据类型分布,供 M6 与概览页 |
| 增量游标 | `pbl_evidence_watermark` | (无独立 dspy供 M5b 回放 / cron 与内部调用) | 已采集最大事件时间 + 总量3s 轮询兜底的起点 |
模块入口页:`/pbl_evidence/index.ui`bricks 卡片导航,聚合上述 9 个 .dspy
### 幂等语义(本模块的核心承诺)
重复采集**绝不**产生第二条证据,也**绝不**报错(学生侧无感):
1. **数据库层**:唯一索引 `uk_ev_dedup(tenant_id, source_event_id, evidence_type)` ——
同一租户下「同一来源事件 + 同一证据类型」最多一行。这是唯一可信的防重屏障。
2. **应用层双保险**:写入前先按 `(tenant_id, source_event_id, evidence_type)` 预查,
命中即 `skipped`(或 `update_existing=True` 时刷新 payload未命中才
`INSERT ... ON DUPLICATE KEY UPDATE`,因此并发/重放同时命中也不会抛 IntegrityError。
3. **去重键**`dedup_key = md5(tenant_id|source_event_id|evidence_type)`32 位定长,
与 VARCHAR(32) 严格对齐),便于外部对账与排障。
4. **事件 → 证据映射是配置化常量表**`evidence_map.EVENT_TO_EVIDENCE`37 条同义词映射 +
7 条忽略黑名单),未知事件类型降级为 `observation` 而不丢事件;不靠 SQL 硬编码、不靠猜。
### 硬约束 C-3上线顺序必须遵守
本模块批量采集读取的 `pbl_runtime_event` 表由 **scense_runtime 的 M11b** 建表并写入。
- **M5 不得早于 M11b 上线**,且 M5 与 M11 不得并行开发联调。
- 事件表缺失时 `collector.resolve_event_table()` 返回 None → 抛 `CollectError`
契约层转成明确错误码 `PBL_E_DB_UNAVAILABLE`**不会**静默返回空成功fail-closed便于运维定位
- 兼容层:事件表名按候选顺序发现 `pbl_runtime_event``runtime_event``pbl_event`
列名一律经 `information_schema` 发现,**不猜列名**(分区表/改名不会导致整链 500
## Data tables数据表
表定义在 `models/{table}.json`(四段式 summary/fields/indexes/codesDDL 由 `json2ddl` 生成,
建表 SQL 快照在 `sql/pbl_evidence.sql`
### `pbl_artifact` —— 学生产出物
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | str(32) PK | 主键 |
| `tenant_id` | str(32) NOT NULL | 租户(强制打头,缺失 fail-closed |
| `artifact_uid` | str(32) | 业务唯一码,与 tenant 组成唯一键 |
| `session_id` / `blueprint_id` / `team_id` | str(32) | 会话 / 蓝图 / 团队归属 |
| `creator_id` | str(32) | 作者(学习者) |
| `title` | str(128) | 标题 |
| `artifact_type` | str(64) | 类型(编码 `pbl_artifact_type`,默认 `other` |
| `content_json` | text | 产出物内容(序列化 JSON |
| `version_no` | int | 版本号(内联,默认 1版本快照由蓝图侧维护 |
| `status` | str(64) | 状态(编码 `pbl_artifact_status`,默认 `draft` |
| `submitted_at` / `created_at` / `updated_at` | datetime | 时间与审计列(**应用层写入**sqlor 不自动填) |
索引:`uk_art_uid(tenant_id, artifact_uid)` 唯一;`idx_art_session` / `idx_art_blueprint` /
`idx_art_creator`(均以 `tenant_id` 打头)。
### `pbl_evidence` —— 学习证据
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | str(32) PK | 主键 |
| `tenant_id` | str(32) NOT NULL | 租户 |
| `artifact_id` | str(32) | 归属产出物(`'0'` = 无强归属的观测类证据) |
| `evidence_type` | str(64) | 证据类型(编码 `pbl_evidence_type`8 类,源自 `evidence_map.EVIDENCE_TYPES` |
| `source_event_id` | str(32) NOT NULL | 来源 runtime_event 事件 ID幂等锚点 |
| `session_id` / `learner_id` / `blueprint_id` | str(32) | 查询维度 |
| `payload_json` | text | 证据体(事件摘要序列化 JSON |
| `occurred_at` | datetime | 事件发生时间 |
| `dedup_key` | str(32) | `md5(tenant\|event\|type)` |
| `collect_batch_id` | str(32) | 采集批次(排障用) |
| `created_at` / `updated_at` | datetime | 审计列(应用层写入) |
索引:
- **`uk_ev_dedup` UNIQUE `(tenant_id, source_event_id, evidence_type)`** —— 幂等的唯一事实源。
含义:同一租户内,一条来源事件的一种证据类型只允许一行;重放、双通道(广播 + 3s 轮询兜底)
命中同一事件时第二次写入被唯一键吸收,不产生重复证据。
- `idx_ev_learner(tenant_id, learner_id, occurred_at)``idx_ev_artifact(tenant_id, artifact_id)`
`idx_ev_type_time(tenant_id, evidence_type, occurred_at)``idx_ev_batch(tenant_id, collect_batch_id)`
CRUD 定义在 `json/evidence_pbl_artifact.json` / `json/evidence_pbl_evidence.json`
`tblname` + `params``editable` 的 new/update/delete_data_url 指向 `wwwroot/api/` 自定义 dspy
## Installation
作为模块,它随宿主应用一起安装(不单独起服务):
```bash
# 1) 安装 Python 包(在宿主 pkgs/ 或机构 modules/ 下)
cd pbl_evidence && pip install -e .
# 2) 生成建表 DDL 并执行models/ 为四段式 JSON用 json2ddl
cd models && json2ddl mysql . > mysql.ddl.sql
mysql -h <db_host> -u<user> -p<pwd> <dbname> < mysql.ddl.sql
# (手工建表务必带 COLLATE utf8mb4_unicode_ci否则与 xls2ddl 产物 collation 混用报 1267
# 3) 生成 CRUD 前端build.sh 步骤json/ 下两个文件按 tblname 命名)
cd json && xls2ui -m ../models -o ../wwwroot pbl_evidence *.json
# -> 生成 wwwroot/pbl_artifact/ 与 wwwroot/pbl_evidence/ 两个 CRUD 目录(构建产物,
# 已在 .gitignore 中排除,部署后由 build.sh 重新生成,禁止手改其中文件)
# 4) 导入编码种子3 组 appcodespbl_artifact_type / pbl_artifact_status / pbl_evidence_type
# Format B由宿主 scripts/init_data.py或 dbloader读入 init/data.json 落 appcodes + appcodes_kv
python3 -c "import json;d=json.load(open('init/data.json'));print('groups:',[g['parentid'] for g in d['appcodes']])"
# 5) 登记 RBAC 权限(双层:模块层 + 中央宿主层,见下)
python3 scripts/load_path.py --check # 清单与 wwwroot 磁盘一致性核对(不连库)
RBAC_SET_PERM=/path/to/set_role_perm.py python3 scripts/load_path.py # 实际写权限
python3 scripts/load_path.py --check --generated # 部署机上xls2ui 已跑)连生成物一起核对
python3 scripts/load_path.py --sql | mysql -h <db_host> -u<user> -p<pwd> <rbac_dbname>
```
**permission / rolepermission 同步(新增路径必做)**`--sql` 输出与 PATHS 一一对应的幂等
INSERT —— `permission.permcode` = 路径本身,`rolepermission``role.rolecode='logined'`
反查 id 关联不含任何硬编码主键重复执行不产生脏数据。rbac CLIset_role_perm.py不在位时
这条管道就是部署说明给出的登记手段。**新增 .dspy 若忘记登记,登录后访问必 403**
QC 硬门禁 #3 的成因)。
`scripts/load_path.py` 逐条显式登记(**禁通配符**),角色均为 `logined`
- 手写资源 13 条:`/pbl_evidence/index.ui` + `wwwroot/api/` 下全部 **12**`.dspy`
(产出物 CRUD 5 + 证据采集 2 + 查询/统计 2 + CRUD 适配 3
`pbl_artifact_options` / `pbl_evidence_update` / `pbl_evidence_delete`
- CRUD 生成物 12 条(`GENERATED_PATHS``/pbl_evidence/{pbl_artifact,pbl_evidence}` 目录 +
`index.ui` + `get_/add_/update_/delete_*.dspy`(每 alias 6 条)。这些是 xls2ui 产物、开发仓库
不落盘,故 `--check` 默认不计 stale部署后加 `--generated` 再核。
中央宿主 `apps/pbls/scripts/load_path.py`
另有一份等价的硬编码兜底清单(不同代码路径,模块脚本 import 失败时仍能登记)——
双层 RBAC 回退,两层都必须在册。
自检(无需连库,可放 CI
> **占位说明**:离线自检脚本(三处同步 / RBAC 清单 vs 磁盘 / 模型四段式 / 幂等键语义)
> 由后续子任务 M5a-2 产出,**当前磁盘上尚不存在**,故本 README 不引用其文件名。
> 在它落地前,用下列**当前磁盘上真实存在**的命令完成同等自检:
```bash
# ① 依赖可安装pyproject 只声明 sqlor不拉基础包
pip install .
# ② 编码种子 JSON 合法 + parentid 与 models/*.json 的 codes.cond 一致
python3 -c "import json;d=json.load(open('init/data.json'));print([g['parentid'] for g in d['appcodes']])"
grep -o "parentid='[a-z_]*'" models/*.json | sort -u
# ③ 三处同步api.py(10) + crud_api.py(3) 契约定义 == __init__.py 导出 == init.py env 注册(共 13
grep -c "^async def pbl_" pbl_evidence/api.py pbl_evidence/crud_api.py
grep -c "^ env.pbl_" pbl_evidence/init.py
grep -rn 'pbl_evidence_update' pbl_evidence/ --include='*.py' # 定义/导出/__all__/import/env 全命中
# ③b CRUD 端点存在性json/*.json 引用的每个 ../api/*.dspy 必须真实落盘(否则编辑/删除/下拉 404
python3 scripts/check_crud_endpoints.py
# ③c CRUD JSON 合法键 + alias 唯一性自检
python3 scripts/check_crud_json.py
# ④ RBAC 清单与 wwwroot 磁盘一致性核对(不连库)
python3 scripts/load_path.py --check
```
## Integration宿主挂载方式
模块只依赖基础包sqlor、ahserver ServerEnv、appPublic 工具)、`pbl_common`(租户上下文/
错误码/时间序列化)、以及自己的两张表。**不依赖任何宿主入口文件或宿主的 wwwroot 路径。**
```python
# apps/pbls/app/pbls.py
from ahserver.serverenv import ServerEnv
from appbase.init import load_appbase
from rbac.init import load_rbac
from bricks_for_python.init import load_pybricks
def get_module_dbname(m):
# 库名由宿主决定;本模块绝不硬编码 DBNAME
return MODULE_DBNAME_MAP.get(m, DEFAULT_DBNAME)
def init():
env = ServerEnv() # 必须在 load_rbac() 之前
env.get_module_dbname = get_module_dbname # ← 宿主必须先注册,否则本模块 fail-closed
load_appbase(); load_rbac(); load_pybricks()
from pbl_evidence.init import load_pbl_evidence
load_pbl_evidence() # 唯一集成点
```
**前置条件**:宿主必须在 `ServerEnv` 上注册 `get_module_dbname`。取不到库名时本模块直接抛
`PblError`fail-closed不会退化成查错库。租户上下文由 `pbl_common.api.tenant_id()` 提供,
所有读写强制带 `tenant_id`,缺失即报错。
函数注册三处同步(改一处必须改三处):
`pbl_evidence/api.py` 定义 → ② `pbl_evidence/__init__.py` 导出 → ③ `pbl_evidence/init.py`
`env.<name> = <name>`。漏 ② → import 期 ImportError漏 ③ → .dspy 调用 NameError。
新增对外路径还要同步 ④ `scripts/load_path.py`+ 中央 `apps/pbls/scripts/load_path.py`)。
### 接口返回形态wwwroot/api/*.dspy三类勿混用
| 端点类别 | 返回体 | 依据 |
|---|---|---|
| **DataViewer editable 提交端点**`pbl_evidence_update.dspy``pbl_evidence_delete.dspy` | `json.dumps({'widgettype':'Message','options':{'title','message','type'}})` **字符串**,成功 `type='success'`、异常 `type='error'` | dspy-file-implementation-spec「DataViewer CRUD endpoints 必须返回 Message widget JSON 字符串,不得返回裸数据」 |
| **下拉数据源**`pbl_artifact_options.dspy` | 裸数组 `[{value,text}]`,异常降级 `[]` | crud-definition-spec包 status/data 会让下拉静默不渲染) |
| **契约直调端点**`pbl_artifact_{create,read,update,delete,list}.dspy``pbl_evidence_{collect,collect_from_events,list,stats}.dspy` | 直接 `return` 后端函数的结构化 dict`status/data/message/total` | module-development-spec 2.5(薄包装,不加工返回体) |
前两类是 editable/表单相关端点、第三类是程序化契约端点,形态差异**有意为之**
不构成同目录格式分裂(后端 `crud_api.py` 仍返回结构化 dict由 dspy 层转 Message
**写 dspy 的铁律**dict 字面量内**禁止** f-string`{'message': f'...{e}'}` 会让 ahserver
`exec()` 误判提前闭合外层 dict → `SyntaxError: '{' was never closed` → 整文件编译失败),
一律 `'前缀:' + str(e)` 拼接;顶部 `debug(f'...')` 前缀日志不受此限。
自查:`grep -n "f'" wwwroot/api/*.dspy | grep -v debug` 无输出。
## 目录结构
```
pbl_evidence/
├── pbl_evidence/ # Python 包(= 模块名)
│ ├── __init__.py # 导出(三处同步之 ②)
│ ├── init.py # load_pbl_evidence()(三处同步之 ③)
│ ├── api.py # 10 个业务契约函数(三处同步之 ①)
│ ├── crud_api.py # 3 个 CRUD 框架适配契约options/update/delete三处同步之 ①)
│ ├── collector.py # 事件扫描 → 映射 → 幂等落库list/stats/watermark
│ ├── evidence_map.py # 纯函数层事件→证据类型映射、dedup_key、时间规范化
│ └── db.py # 数据访问层get_module_dbname、列名发现、q_all/q_one/q_exec
├── models/ # pbl_artifact.json / pbl_evidence.json四段式表定义
├── json/ # CRUD 定义tblname + params + editable
├── wwwroot/ # index.ui + api/ 12 个 .dspy自动路由 /pbl_evidence/...
├── init/data.json # 编码种子Format B3 组 appcodes + appcodes_kv 子项)
├── sql/pbl_evidence.sql # 建表 SQL 快照
├── scripts/load_path.py # RBAC 登记(模块层,禁通配符)+ --check 一致性核对
├── skill/SKILL.md # 面向 agent 的模块技能文档
└── pyproject.toml
```
## 已知边界(如实说明)
- 批量采集(`pbl_evidence_collect_from_events`)的真实回放**未在本地环境跑通**:该环境
`information_schema.tables` 中不存在 `pbl_runtime_event`M11b 未上线),采集会按设计抛
`CollectError → PBL_E_DB_UNAVAILABLE`。已在 M11b 落库的联调环境验证前,此项标注为「待联调」。
- `pbl_evidence_watermark` 只作为契约函数注册(供 M5b/cron 内部调用),未单独暴露 .dspy
如需外部轮询调用,新增 .dspy 后必须同步登记 RBAC。