# 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 -u -p < 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 -u -p ``` **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. = `。漏 ② → 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。