diff --git a/README.md b/README.md index 5195b5d..43ce285 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,195 @@ # 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(64) | 业务唯一码,与 tenant 组成唯一键 | +| `session_id` / `blueprint_id` / `team_id` | str(32) | 会话 / 蓝图 / 团队归属 | +| `creator_id` | str(32) | 作者(学习者) | +| `title` | str(255) | 标题 | +| `artifact_type` | str(64) | 类型(编码 `pbl_artifact_type`) | +| `content_json` | text | 产出物内容(序列化 JSON) | +| `version_no` | str(32) | 版本号(内联,版本快照由蓝图侧维护) | +| `status` | str(32) | 状态(编码 `pbl_artifact_status`) | +| `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 类) | +| `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 -u -p < mysql.ddl.sql +# (手工建表务必带 COLLATE utf8mb4_unicode_ci,否则与 xls2ddl 产物 collation 混用报 1267) + +# 3) 生成 CRUD 前端(可选;已有手写 api/ dspy 时按 build.sh 流程执行) +cd json && xls2ui -m ../models -o ../wwwroot pbl_evidence *.json + +# 4) 登记 RBAC 权限(双层:模块层 + 中央宿主层,见下) +python3 scripts/load_path.py --check # 清单与 wwwroot 磁盘一致性核对(不连库) +RBAC_SET_PERM=/path/to/set_role_perm.py python3 scripts/load_path.py # 实际写权限 +``` + +`scripts/load_path.py` 逐条显式登记(**禁通配符**):`/pbl_evidence/index.ui` 与 +`wwwroot/api/` 下全部 9 个 `.dspy`,角色均为 `logined`。中央宿主 `apps/pbls/scripts/load_path.py` +另有一份等价的硬编码兜底清单(不同代码路径,模块脚本 import 失败时仍能登记)—— +双层 RBAC 回退,两层都必须在册。 + +自检(无需连库,可放 CI): + +```bash +python3 scripts/selfcheck_m5a.py # 三处同步 / RBAC 清单 vs 磁盘 / 模型四段式 / 幂等键语义 +``` + +## 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. = `。漏 ② → import 期 ImportError;漏 ③ → .dspy 调用 NameError。 +新增对外路径还要同步 ④ `scripts/load_path.py`(+ 中央 `apps/pbls/scripts/load_path.py`)。 + +## 目录结构 + +``` +pbl_evidence/ +├── pbl_evidence/ # Python 包(= 模块名) +│ ├── __init__.py # 导出(三处同步之 ②) +│ ├── init.py # load_pbl_evidence()(三处同步之 ③) +│ ├── api.py # 10 个契约函数(三处同步之 ①) +│ ├── 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/ 9 个 .dspy(自动路由 /pbl_evidence/...) +├── sql/pbl_evidence.sql # 建表 SQL 快照 +├── scripts/load_path.py # RBAC 登记(模块层,禁通配符)+ --check 一致性核对 +├── scripts/selfcheck_m5a.py # 离线自检(三处同步 / RBAC / 模型 / 幂等键) +├── 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。