257 lines
16 KiB
Markdown
257 lines
16 KiB
Markdown
# 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/codes),DDL 由 `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 组 appcodes:pbl_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 CLI(set_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 B:3 组 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。
|