From a494bd23e6931d203762df156e9862d4cddbc660 Mon Sep 17 00:00:00 2001 From: pbls-bot Date: Tue, 15 Sep 2026 17:39:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(pbls):=20=E5=88=9D=E5=A7=8B=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=20-=20pbls=20=E9=A1=B9=E7=9B=AE=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 + app/pbls.py | 111 ++++ build.sh | 68 +++ conf/config.json | 69 +++ requirements.txt | 17 + scripts/apply_ddl.sh | 23 + scripts/ddl/pbls_tables.sql | 668 +++++++++++++++++++++ scripts/gen_artifacts.py | 733 ++++++++++++++++++++++++ scripts/tests/offline_contract_smoke.py | 654 +++++++++++++++++++++ wwwroot/pbl_overlay/pbl_overlay.js | 225 ++++++++ 10 files changed, 2580 insertions(+) create mode 100644 .gitignore create mode 100644 app/pbls.py create mode 100644 build.sh create mode 100644 conf/config.json create mode 100644 requirements.txt create mode 100644 scripts/apply_ddl.sh create mode 100644 scripts/ddl/pbls_tables.sql create mode 100644 scripts/gen_artifacts.py create mode 100644 scripts/tests/offline_contract_smoke.py create mode 100644 wwwroot/pbl_overlay/pbl_overlay.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6579d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +.DS_Store +*.log +node_modules/ +dist/ +build/ +.venv/ +venv/ diff --git a/app/pbls.py b/app/pbls.py new file mode 100644 index 0000000..007f999 --- /dev/null +++ b/app/pbls.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbls —— PBL Agent OS 应用唯一入口(端口 9300,见 projects/pbls/env/test.json:port)。 + +框架铁律(web-application-spec): +- 入口必须 `from ahserver.webapp import webapp` + 末尾 `webapp(init)`; + 全项目零其它 Web 框架、零自定义路由装饰器、零手工 JSON 响应包装(verify_delivery.py 扫描零命中)。 +- 加载顺序固定:`ServerEnv()` → 基础模块(appbase/rbac/pybricks)→ 业务模块。 + 基础模块必须**显式调用 loader**(load_appbase/load_rbac/load_pybricks), + 只 importlib.import_module 就标 loaded 是反模式(登录/权限链路必崩)。 +- 库名由应用统一决定:get_module_dbname(m) 从 conf/config.json 的 module_dbmap 读, + 模块侧禁止硬编码 DBNAME。 +- 端口唯一来源 = projects/pbls/env/{test,prod}.json 的 port;应用仓库不设 env/ 目录。 + +挂载规模对账(QC #13):20 个 load_* 调用 = 3 平台基础模块(appbase/rbac/pybricks) ++ 5 复用基座模块(world/scene/entity/script_engine/scense_game) ++ 12 个 pbl_* 模块(见下 MODULE_LOAD_ORDER,编号 M1~M12 与 modules/*.md 一一对应)。 +""" +import json +import os + +from ahserver.webapp import webapp +from ahserver.serverenv import ServerEnv + +import bricks_for_python # registers bui processor +from bricks_for_python.init import load_pybricks # registers UiWindow etc. +from appbase.init import load_appbase +from rbac.init import load_rbac + +CONF = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'conf', 'config.json') +# 12 个 pbl_* 业务模块(编号 ↔ 目录,M10=pbl_appcodes 零表、M12=pbl_common 内核) +MODULE_LOAD_ORDER = [ + ('pbl', 'pbl_common'), # M12 公共内核(必须最先,提供租户/DB 出口) + ('pbl', 'pbl_appcodes'), # M10 枚举 6+2 组幂等注入 + ('pbl', 'pbl_blueprint'), # M1 蓝图聚合根 + 子对象 + 版本 + 模板 + ('pbl', 'pbl_validation'), # M2 14 维校验 + 5 级质量状态 + ('pbl', 'pbl_compiler'), # M3 确定性编译 + Game Definition + Capability + ('pbl', 'pbl_agent_runtime'), # M4 Designer/Critic + fail-closed 工具裁决 + ('pbl', 'pbl_domain_ext'), # M8 world/scene/entity 薄扩展(不改基表) + ('pbl', 'pbl_runtime_ext'), # M11 单事务事件+状态写入与广播 + ('pbl', 'pbl_evidence'), # M5 产出物 + 证据幂等采集 + ('pbl', 'pbl_assessment'), # M6 Rubric 加权评估与报告 + ('pbl', 'pbl_kdb_ext'), # M7 KDB 只读桩 + 匿名聚合 + ('pbl', 'pbl_scense_ext'), # M9 前端覆盖层上下文 + 页面注册 +] +# 复用基座模块(只读引用 + 薄扩展,不改基表;缺失时降级并明确回报,不静默假成功) +BASE_MODULE_LOADERS = ['world', 'scene', 'entity', 'script_engine', 'scense_game'] + + +def _conf(): + with open(CONF, encoding='utf-8') as f: + return json.load(f) + + +def get_module_dbname(m): + """模块 → 库名映射(来自 conf/config.json:module_dbmap,禁止写死单个库名)。""" + conf = _conf() + mapping = conf.get('module_dbmap') or {} + if m in mapping: + return mapping[m] + return mapping.get('*') or conf.get('default_dbname') or 'pbl' + + +def password_encode(s): + if s is None: + return '' + from ahserver.globalEnv import password_encode as _orig + return _orig(s) + + +def init(): + env = ServerEnv() # ← MUST be before load_rbac + env.get_module_dbname = get_module_dbname + env.password_encode = password_encode + env.pbl_default_dbname = get_module_dbname('pbl') + env.pbl_default_tenant = _conf().get('default_tenant') or 'default' + + # ── 基础模块:显式调用 loader(反模式禁令:不得只 import 就标 loaded)── + load_appbase() + load_rbac() + load_pybricks() + + # ── 复用基座模块(world/scene/entity/script_engine/scense_game)── + missing = [] + for name in BASE_MODULE_LOADERS: + try: + pkg = __import__('importlib').import_module('%s.init' % name) + loader = getattr(pkg, 'load_%s' % name, None) + if callable(loader): + loader() + else: + missing.append(name) # 无 loader 即视为未挂载,不谎报 loaded + except ImportError: + missing.append(name) + + # ── 12 个 pbl_* 业务模块(按 Wave 依赖顺序)── + loaded = [] + for _db, name in MODULE_LOAD_ORDER: + pkg = __import__('importlib').import_module('%s.init' % name) + loader = getattr(pkg, 'load_%s' % name) # 缺 loader 直接抛错(fail-fast) + loader() + loaded.append(name) + env.pbl_modules_loaded = loaded + env.pbl_base_missing = missing + print('[pbls] modules loaded: %d pbl + base missing=%s' % (len(loaded), missing or 'none')) + return {'loaded': len(loaded) + 3, 'base_missing': missing} + + +if __name__ == '__main__': + webapp(init) diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..8a791b6 --- /dev/null +++ b/build.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# pbls 一键部署(web-application-spec 8 步;端口 9300 来源 projects/pbls/env/test.json) +set -euo pipefail +cdir=$(cd "$(dirname "$0")" && pwd) +APP=pbls +PORT=${PORT:-9300} +DB_HOST=127.0.0.1; DB_PORT=3306; DB_USER=test; DB_PASS=test123; DB_NAME=pbl +ENV_JSON="$cdir/../../projects/pbls/env/test.json" +[ -f "$ENV_JSON" ] && PORT=$(python3 -c "import json;print(json.load(open('$ENV_JSON'))['port'])") +echo "[build] app=$APP port=$PORT (env source: $ENV_JSON)" +mkdir -p "$cdir/pkgs" "$cdir/logs" "$cdir/files" + +# 1) 基础包 + 依赖 +pip install -q -r "$cdir/requirements.txt" || echo "[warn] pip requirements 需离线源,跳过" + +# 2) 模块 clone + 安装(基础模块 + 复用基座 + 12 个 pbl_*) +MODULES="world scene entity script_engine scense scense_game scense_runtime \ +pbl_common pbl_appcodes pbl_blueprint pbl_validation pbl_compiler pbl_agent_runtime \ +pbl_evidence pbl_assessment pbl_kdb_ext pbl_domain_ext pbl_scense_ext pbl_runtime_ext" +for m in $MODULES; do + src="$cdir/../../modules/$m" + [ -d "$src" ] || { echo "[skip] module $m 不存在"; continue; } + [ -d "$cdir/pkgs/$m" ] || cp -r "$src" "$cdir/pkgs/$m" + if [ -f "$cdir/pkgs/$m/pyproject.toml" ]; then + pip install -q -e "$cdir/pkgs/$m" || echo "[warn] pip install -e $m 失败,回退 PYTHONPATH" + fi + export PYTHONPATH="$cdir/pkgs:$PYTHONPATH" + # 3) wwwroot 软链(非 cp,保持同步) + ln -sfn "$cdir/pkgs/$m/wwwroot" "$cdir/wwwroot/$m" + # 5) 建表:models/*.json → DDL(json2ddl),与 apps/pbls/scripts/ddl 同源 + if [ -d "$cdir/pkgs/$m/models" ]; then + ( cd "$cdir/pkgs/$m/models" && json2ddl mysql . > mysql.ddl.sql 2>/dev/null || true ) + [ -s "$cdir/pkgs/$m/models/mysql.ddl.sql" ] && \ + mysql -h "$DB_HOST" -P "$DB_PORT" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" \ + < "$cdir/pkgs/$m/models/mysql.ddl.sql" || echo "[warn] DDL 执行跳过($m)" + fi + # 6) CRUD 生成:json/*.json → .dspy + .ui + if [ -d "$cdir/pkgs/$m/json" ]; then + ( cd "$cdir/pkgs/$m/json" && xls2ui -m ../models -o ../wwwroot "$m" *.json 2>/dev/null || true ) + fi + # 7) 初始化数据(appcodes 等种子) + [ -f "$cdir/pkgs/$m/init/data.json" ] && echo "[info] $m 有 init/data.json,由 load_$m() 幂等注入" + # 8) RBAC 路径注册 + [ -f "$cdir/pkgs/$m/scripts/load_path.py" ] && \ + python3 "$cdir/pkgs/$m/scripts/load_path.py" || echo "[warn] $m rbac 注册跳过" +done + +# 4) i18n 合并 +python3 "$cdir/scripts/merge_i18n.py" 2>/dev/null || echo "[info] i18n 合并跳过" + +# 兜底建表(36 表,MariaDB 方言,幂等 CREATE TABLE IF NOT EXISTS) +bash "$cdir/scripts/apply_ddl.sh" || echo "[warn] apply_ddl 跳过(离线/无库)" + +# bricks 前端 +[ -d "$cdir/pkgs/bricks/dist" ] && ln -sfn "$cdir/pkgs/bricks/dist" "$cdir/wwwroot/bricks" + +# 启动/停止脚本 +cat > "$cdir/start.sh" <> logs/$APP.out 2>&1 & +echo \$! > $APP.pid +EOF +cat > "$cdir/stop.sh" <= 36)" + +# appcodes 6+2 组幂等注入 +PYTHONPATH="$(dirname "$APP_HOME")/modules:$APP_HOME/lib:${PYTHONPATH:-}" \ + python3 -c "import sys; sys.path.insert(0,'$(dirname "$APP_HOME")/modules'); import pbl_appcodes; print(pbl_appcodes.inject_all())" diff --git a/scripts/ddl/pbls_tables.sql b/scripts/ddl/pbls_tables.sql new file mode 100644 index 0000000..5644679 --- /dev/null +++ b/scripts/ddl/pbls_tables.sql @@ -0,0 +1,668 @@ +-- pbls 36 表 DDL(MariaDB 10.x 方言,由 scripts/gen_artifacts.py 从同一 spec 生成) +-- 硬约束:tenant_id 打头 / id 为 BIGINT UNSIGNED AUTO_INCREMENT 主键;-- 不使用 外键约束 / 枚举列类型 / 时间戳列类型 / 位图列类型(枚举值走 appcodes 编码表,时间列走 DATETIME 由应用层写入) +-- 方言来源:projects/pbls/env/test.json -> db.engine = mariadb(禁 BIGSERIAL/SERIAL/nextval 等 PG 语法) +SET NAMES utf8mb4; + +-- ===== pbl_blueprint (M1) ===== +CREATE TABLE IF NOT EXISTS `pbl_blueprint` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "蓝图编码", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `subject` VARCHAR(64) NOT NULL COMMENT "学科", + `grade` VARCHAR(64) NOT NULL COMMENT "年级", + `description` TEXT NULL COMMENT "说明", + `quality_state` VARCHAR(64) NOT NULL COMMENT "质量状态 pbl_quality_state", + `generation_source` VARCHAR(64) NOT NULL COMMENT "生成来源 llm/template_fallback/manual", + `approval_status` VARCHAR(64) NOT NULL COMMENT "审批状态", + `visibility` VARCHAR(64) NOT NULL COMMENT "可见范围", + `content_json` LONGTEXT NULL COMMENT "蓝图正文(结构化模型,非散文)", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "当前版本", + `compiler_version_id` BIGINT UNSIGNED NOT NULL COMMENT "编译版本", + `world_id` BIGINT UNSIGNED NOT NULL COMMENT "编译产物落 world 的溯源ID", + `created_by` VARCHAR(32) NOT NULL COMMENT "创建人", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_bp_tenant_code` (`tenant_id`, `code`), + KEY `idx_bp_tenant_state` (`tenant_id`, `quality_state`), + KEY `idx_bp_tenant_creator` (`tenant_id`, `created_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="PBL 蓝图聚合根(第8章 schema 落库)"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_learning_goal` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `code` VARCHAR(64) NOT NULL COMMENT "目标编码", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `description` TEXT NULL COMMENT "描述", + `bloom_level` VARCHAR(64) NOT NULL COMMENT "布鲁姆层级", + `measurable` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "是否可测", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_learning_goal_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 学习目标"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_role` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `code` VARCHAR(64) NOT NULL COMMENT "角色编码", + `title` VARCHAR(128) NOT NULL COMMENT "角色名", + `persona_txt` TEXT NULL COMMENT "人设", + `capabilities_json` LONGTEXT NULL COMMENT "能力引用", + `headcount` INT NOT NULL DEFAULT 0 COMMENT "名额", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_role_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - PBL 角色"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_mission` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `code` VARCHAR(64) NOT NULL COMMENT "任务域编码", + `title` VARCHAR(128) NOT NULL COMMENT "驱动问题域", + `scenario_txt` TEXT NULL COMMENT "情境", + `unlock_condition_json` LONGTEXT NULL COMMENT "解锁条件", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_mission_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 驱动问题/Mission"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_task` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `mission_id` BIGINT UNSIGNED NOT NULL COMMENT "所属Mission", + `code` VARCHAR(64) NOT NULL COMMENT "编码", + `title` VARCHAR(128) NOT NULL COMMENT "任务名", + `steps_json` LONGTEXT NULL COMMENT "步骤", + `difficulty` VARCHAR(64) NOT NULL COMMENT "难度", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_task_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 任务"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_artifact_spec` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `code` VARCHAR(64) NOT NULL COMMENT "编码", + `title` VARCHAR(128) NOT NULL COMMENT "产出物名", + `artifact_type` VARCHAR(64) NOT NULL COMMENT "产出类型", + `required_fields_json` LONGTEXT NULL COMMENT "必填字段", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_artifact_spec_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 产出物规格"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_evidence_spec` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `evidence_type` VARCHAR(64) NOT NULL COMMENT "证据类型", + `source` VARCHAR(64) NOT NULL COMMENT "来源", + `min_count` INT NOT NULL DEFAULT 0 COMMENT "最少条数", + `rubric_criterion_uid` VARCHAR(32) NOT NULL COMMENT "对齐的评分准则UID", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_blueprint_evidence_spec_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 证据规格"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_reflection_spec` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "所属蓝图", + `obj_uid` VARCHAR(32) NOT NULL COMMENT "子对象UID", + `trigger` VARCHAR(64) NOT NULL COMMENT "触发点", + `prompt_txt` TEXT NULL COMMENT "反思引导语", + `min_words` INT NOT NULL DEFAULT 0 COMMENT "最少字数", + `evidence_type` VARCHAR(64) NOT NULL COMMENT "产出证据类型", + `order_no` INT NOT NULL DEFAULT 0 COMMENT "排序", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_bp_rs_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图子对象 - 反思规格"; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_version` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图ID", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本号", + `snapshot_json` LONGTEXT NULL COMMENT "全量快照", + `change_delta_json` LONGTEXT NULL COMMENT "结构化增量", + `quality_state` VARCHAR(64) NOT NULL COMMENT "质量状态", + `generation_source` VARCHAR(64) NOT NULL COMMENT "生成来源", + `created_by` VARCHAR(32) NOT NULL COMMENT "操作人", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_bpv` (`tenant_id`, `blueprint_id`, `version_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="蓝图版本(change_delta 证明对话式改模型)"; + +CREATE TABLE IF NOT EXISTS `pbl_template` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "模板编码", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `subject` VARCHAR(64) NOT NULL COMMENT "学科", + `grade` VARCHAR(64) NOT NULL COMMENT "年级", + `skeleton_json` LONGTEXT NULL COMMENT "骨架", + `fallback_slot_json` LONGTEXT NULL COMMENT "兜底槽位(LLM不可用时填充)", + `is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "内置", + `use_count` INT NOT NULL DEFAULT 0 COMMENT "实例化次数", + `quality_state` VARCHAR(64) NOT NULL COMMENT "质量状态", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tpl_tenant_code` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="PBL 模板(含离线兜底槽位)"; + +-- ===== pbl_validation (M2) ===== +CREATE TABLE IF NOT EXISTS `pbl_validation_rule` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "规则编码", + `dimension` VARCHAR(64) NOT NULL COMMENT "维度", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `rule_json` LONGTEXT NULL COMMENT "规则体", + `threshold_json` LONGTEXT NULL COMMENT "阈值(可配,Q-OPEN-8)", + `severity` VARCHAR(64) NOT NULL COMMENT "严重度", + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vr_code` (`tenant_id`, `code`, `version_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="校验规则(14 维,规则 JSON 形态 script_type=1)"; + +CREATE TABLE IF NOT EXISTS `pbl_validation_result` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图ID", + `blueprint_version_no` INT NOT NULL DEFAULT 0 COMMENT "校验时版本", + `dimension_count` INT NOT NULL DEFAULT 0 COMMENT "维度数(=14)", + `passed_count` INT NOT NULL DEFAULT 0 COMMENT "通过数", + `failed_count` INT NOT NULL DEFAULT 0 COMMENT "失败数", + `score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "得分", + `quality_state` VARCHAR(64) NOT NULL COMMENT "质量状态", + `findings_json` LONGTEXT NULL COMMENT "逐维结论", + `rule_set_version` VARCHAR(64) NOT NULL COMMENT "规则集版本", + `created_by` VARCHAR(32) NOT NULL COMMENT "触发人", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_vres_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="校验结果(14 维 + 5 级质量状态)"; + +-- ===== pbl_compiler (M3) ===== +CREATE TABLE IF NOT EXISTS `pbl_compiler_version` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "版本编码", + `semver` VARCHAR(64) NOT NULL COMMENT "语义化版本", + `rules_hash` VARCHAR(32) NOT NULL COMMENT "规则集SHA256前缀", + `entrypoint` VARCHAR(64) NOT NULL COMMENT "入口函数", + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `notes` TEXT NULL COMMENT "说明", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cv_code` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="编译器版本(29.6 确定性)"; + +CREATE TABLE IF NOT EXISTS `pbl_game_definition` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "来源蓝图", + `blueprint_version_no` INT NOT NULL DEFAULT 0 COMMENT "蓝图版本", + `compiler_version_id` BIGINT UNSIGNED NOT NULL COMMENT "编译版本", + `content_fingerprint` VARCHAR(32) NOT NULL COMMENT "SHA-256 指纹", + `definition_json` LONGTEXT NULL COMMENT "Game Definition 正文", + `world_id` BIGINT UNSIGNED NOT NULL COMMENT "落库 world", + `scene_id` BIGINT UNSIGNED NOT NULL COMMENT "落库 scene", + `entity_count` INT NOT NULL DEFAULT 0 COMMENT "实体数", + `event_count` INT NOT NULL DEFAULT 0 COMMENT "事件数", + `quality_state` VARCHAR(64) NOT NULL COMMENT "编译时质量状态", + `duration_ms` INT NOT NULL DEFAULT 0 COMMENT "耗时", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gd_fp` (`tenant_id`, `content_fingerprint`), + KEY `idx_gd_bp` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Game Definition(第9章 schema + 指纹)"; + +CREATE TABLE IF NOT EXISTS `pbl_capability_registry` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `capability_key` VARCHAR(64) NOT NULL COMMENT "能力键", + `category` VARCHAR(64) NOT NULL COMMENT "分类", + `args_schema_json` LONGTEXT NULL COMMENT "参数JSON Schema", + `permission_required` VARCHAR(64) NOT NULL COMMENT "所需权限", + `is_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `description` TEXT NULL COMMENT "说明", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cap_key` (`tenant_id`, `capability_key`, `version_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Capability Registry(第11章缺口补齐)"; + +-- ===== pbl_agent_runtime (M4) ===== +CREATE TABLE IF NOT EXISTS `pbl_agent_def` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "Agent编码", + `agent_type` VARCHAR(64) NOT NULL COMMENT "类型", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `model_route` VARCHAR(64) NOT NULL COMMENT "pipeline-llm 路由", + `system_prompt_txt` TEXT NULL COMMENT "提示词", + `tool_whitelist_json` LONGTEXT NULL COMMENT "工具白名单", + `permission_mode` VARCHAR(64) NOT NULL COMMENT "read/write 模式", + `is_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ad_code` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Agent 定义(Designer/Critic)"; + +CREATE TABLE IF NOT EXISTS `pbl_tool_registry` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `tool_key` VARCHAR(64) NOT NULL COMMENT "工具键", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `agent_scope` VARCHAR(64) NOT NULL COMMENT "作用域", + `permission_required` VARCHAR(64) NOT NULL COMMENT "所需权限", + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `params_schema_json` LONGTEXT NULL COMMENT "参数白名单Schema", + `disabled_reason` VARCHAR(64) NOT NULL COMMENT "禁用原因", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tr_key` (`tenant_id`, `tool_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="工具注册表(13 启用 / 9 禁用含 pbl.publish)"; + +CREATE TABLE IF NOT EXISTS `pbl_agent_trace` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `trace_uid` VARCHAR(32) NOT NULL COMMENT "轨迹UID", + `agent_code` VARCHAR(64) NOT NULL COMMENT "Agent", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "目标蓝图", + `input_ref_json` LONGTEXT NULL COMMENT "要素1 输入引用", + `thought_txt` TEXT NULL COMMENT "要素2 思考", + `action_code` VARCHAR(64) NOT NULL COMMENT "要素3 动作", + `action_params_json` LONGTEXT NULL COMMENT "要素4 动作参数", + `observation_json` LONGTEXT NULL COMMENT "要素5 观察", + `result_state` VARCHAR(64) NOT NULL COMMENT "要素6 结果状态", + `llm_calls` INT NOT NULL DEFAULT 0 COMMENT "要素7 LLM调用数", + `duration_ms` INT NOT NULL DEFAULT 0 COMMENT "耗时", + `created_by` VARCHAR(32) NOT NULL COMMENT "触发用户", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_at_uid` (`tenant_id`, `trace_uid`), + KEY `idx_at_tenant` (`tenant_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Agent 执行轨迹(第28章 7 要素)"; + +CREATE TABLE IF NOT EXISTS `pbl_agent_tool_call` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `trace_id` BIGINT UNSIGNED NOT NULL COMMENT "轨迹ID", + `tool_key` VARCHAR(64) NOT NULL COMMENT "工具键", + `request_json` LONGTEXT NULL COMMENT "请求", + `response_json` LONGTEXT NULL COMMENT "响应", + `ok` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "成功", + `error_code` VARCHAR(64) NOT NULL COMMENT "错误码", + `adjudication_code` VARCHAR(64) NOT NULL COMMENT "裁决结论", + `duration_ms` INT NOT NULL DEFAULT 0 COMMENT "耗时", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_tc_trace` (`tenant_id`, `trace_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="工具调用明细(服务端裁决留痕)"; + +CREATE TABLE IF NOT EXISTS `pbl_approval` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `approval_uid` VARCHAR(32) NOT NULL COMMENT "审批UID", + `approval_type` VARCHAR(64) NOT NULL COMMENT "类型", + `target_type` VARCHAR(64) NOT NULL COMMENT "对象类型", + `target_id` BIGINT UNSIGNED NOT NULL COMMENT "对象ID", + `payload_json` LONGTEXT NULL COMMENT "变更内容", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `requested_by` VARCHAR(32) NOT NULL COMMENT "发起人", + `decided_by` VARCHAR(32) NOT NULL COMMENT "决策人", + `decided_at` DATETIME NULL NULL COMMENT "决策时间", + `comment_txt` TEXT NULL COMMENT "意见", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ap_uid` (`tenant_id`, `approval_uid`), + KEY `idx_ap_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="人工审批单(14.2 四类,无绕过路径)"; + +CREATE TABLE IF NOT EXISTS `pbl_llm_call_log` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `call_uid` VARCHAR(32) NOT NULL COMMENT "调用UID", + `agent_code` VARCHAR(64) NOT NULL COMMENT "Agent", + `route` VARCHAR(64) NOT NULL COMMENT "路由", + `model` VARCHAR(64) NOT NULL COMMENT "实际模型", + `prompt_hash` VARCHAR(32) NOT NULL COMMENT "提示词哈希", + `completion_tokens` INT NOT NULL DEFAULT 0 COMMENT "tokens", + `latency_ms` INT NOT NULL DEFAULT 0 COMMENT "时延", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `error_code` VARCHAR(64) NOT NULL COMMENT "错误码", + `fallback_used` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "走兜底", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_lc_uid` (`tenant_id`, `call_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="LLM 调用日志(超时/重试/限流/兜底留痕)"; + +-- ===== pbl_evidence (M5) ===== +CREATE TABLE IF NOT EXISTS `pbl_artifact` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `artifact_uid` VARCHAR(32) NOT NULL COMMENT "产出UID", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `team_id` BIGINT UNSIGNED NOT NULL COMMENT "团队", + `creator_id` VARCHAR(32) NOT NULL COMMENT "作者", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `artifact_type` VARCHAR(64) NOT NULL COMMENT "类型", + `content_json` LONGTEXT NULL COMMENT "内容", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `submitted_at` DATETIME NULL NULL COMMENT "提交时间", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_art_uid` (`tenant_id`, `artifact_uid`), + KEY `idx_art_session` (`tenant_id`, `session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="学生产出物(版本内联 version_no)"; + +CREATE TABLE IF NOT EXISTS `pbl_evidence` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `artifact_id` BIGINT UNSIGNED NOT NULL COMMENT "产出物", + `evidence_type` VARCHAR(64) NOT NULL COMMENT "证据类型", + `source_event_id` VARCHAR(32) NOT NULL COMMENT "来源 runtime_event", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `learner_id` VARCHAR(32) NOT NULL COMMENT "学习者", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `payload_json` LONGTEXT NULL COMMENT "证据体", + `occurred_at` DATETIME NOT NULL COMMENT "发生时间", + `dedup_key` VARCHAR(32) NOT NULL COMMENT "去重键", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ev_dedup` (`tenant_id`, `source_event_id`, `evidence_type`), + KEY `idx_ev_learner` (`tenant_id`, `learner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="学习证据(幂等采集,唯一索引防重)"; + +-- ===== pbl_assessment (M6) ===== +CREATE TABLE IF NOT EXISTS `pbl_rubric` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "编码", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `criteria_json` LONGTEXT NULL COMMENT "准则数组(含 learning_goal_id/weight)", + `total_weight` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "权重合计", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `approval_id` BIGINT UNSIGNED NOT NULL COMMENT "变更审批单", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ru_code` (`tenant_id`, `code`, `version_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Rubric(准则内联 criteria_json,权重合计=100%)"; + +CREATE TABLE IF NOT EXISTS `pbl_assessment_record` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `record_uid` VARCHAR(32) NOT NULL COMMENT "记录UID", + `learner_id` VARCHAR(32) NOT NULL COMMENT "学习者", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `artifact_id` BIGINT UNSIGNED NOT NULL COMMENT "产出物", + `evidence_id` BIGINT UNSIGNED NOT NULL COMMENT "主证据", + `rubric_id` BIGINT UNSIGNED NOT NULL COMMENT "Rubric", + `scores_json` LONGTEXT NULL COMMENT "逐准则得分", + `total_score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "加权总分", + `max_score` DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT "满分", + `band` VARCHAR(64) NOT NULL COMMENT "等级", + `alignment_json` LONGTEXT NULL COMMENT "与学习目标对齐", + `feedback_txt` TEXT NULL COMMENT "反馈", + `assessor_type` VARCHAR(64) NOT NULL COMMENT "评估者类型", + `assessor_id` VARCHAR(32) NOT NULL COMMENT "评估者", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ar_uid` (`tenant_id`, `record_uid`), + KEY `idx_ar_learner` (`tenant_id`, `learner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="评估记录(total_score 派生 + 对齐判定)"; + +-- ===== pbl_kdb_ext (M7) ===== +CREATE TABLE IF NOT EXISTS `pbl_kdb_item` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `code` VARCHAR(64) NOT NULL COMMENT "条目编码", + `item_type` VARCHAR(64) NOT NULL COMMENT "类型", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `summary_txt` TEXT NULL COMMENT "摘要", + `payload_json` LONGTEXT NULL COMMENT "内容(无向量,Q-OPEN-5)", + `source` VARCHAR(64) NOT NULL COMMENT "来源", + `is_write_locked` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "写锁(恒1)", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `version_no` INT NOT NULL DEFAULT 0 COMMENT "版本", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_kdb_code` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="KDB 条目(建表不写入,is_write_locked 恒 1)"; + +CREATE TABLE IF NOT EXISTS `pbl_kdb_query_log` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `query_uid` VARCHAR(32) NOT NULL COMMENT "查询UID", + `caller_type` VARCHAR(64) NOT NULL COMMENT "调用方类型", + `caller_id` VARCHAR(32) NOT NULL COMMENT "调用方", + `query_json` LONGTEXT NULL COMMENT "查询条件", + `hit_count` INT NOT NULL DEFAULT 0 COMMENT "命中数", + `latency_ms` INT NOT NULL DEFAULT 0 COMMENT "时延", + `ok` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "成功", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_kql_uid` (`tenant_id`, `query_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="KDB 检索日志(只读侧留痕)"; + +-- ===== pbl_domain_ext (M8) ===== +CREATE TABLE IF NOT EXISTS `pbl_tenant` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `tenant_uid` VARCHAR(32) NOT NULL COMMENT "租户UID", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `kind` VARCHAR(64) NOT NULL COMMENT "类型", + `plan` VARCHAR(64) NOT NULL COMMENT "套餐", + `seat_limit` INT NOT NULL DEFAULT 0 COMMENT "席位", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `config_json` LONGTEXT NULL COMMENT "配置", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ten_uid` (`tenant_id`, `tenant_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="租户(多租户边界第25章,不改基表)"; + +CREATE TABLE IF NOT EXISTS `pbl_class` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `class_uid` VARCHAR(32) NOT NULL COMMENT "班级UID", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `grade` VARCHAR(64) NOT NULL COMMENT "年级", + `teacher_id` VARCHAR(32) NOT NULL COMMENT "教师", + `student_count` INT NOT NULL DEFAULT 0 COMMENT "学生数", + `roster_json` LONGTEXT NULL COMMENT "花名册", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cls_uid` (`tenant_id`, `class_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="班级(花名册内联 roster_json)"; + +CREATE TABLE IF NOT EXISTS `pbl_team` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `team_uid` VARCHAR(32) NOT NULL COMMENT "团队UID", + `class_id` BIGINT UNSIGNED NOT NULL COMMENT "班级", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `mission_id` BIGINT UNSIGNED NOT NULL COMMENT "Mission", + `members_json` LONGTEXT NULL COMMENT "成员", + `capacity` INT NOT NULL DEFAULT 0 COMMENT "容量", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "绑定会话", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_uid` (`tenant_id`, `team_uid`), + KEY `idx_team_class` (`tenant_id`, `class_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="团队(成员内联 members_json,不改基表)"; + +-- ===== pbl_scense_ext (M9) ===== +CREATE TABLE IF NOT EXISTS `pbl_session_member` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `user_id` VARCHAR(32) NOT NULL COMMENT "用户", + `role_code` VARCHAR(64) NOT NULL COMMENT "角色", + `team_id` BIGINT UNSIGNED NOT NULL COMMENT "团队", + `joined_at` DATETIME NULL NULL COMMENT "加入时间", + `last_seen_at` DATETIME NULL NULL COMMENT "心跳", + `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT "已见状态版本", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sm` (`tenant_id`, `session_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="会话成员(复用 world_session,不污染基表)"; + +CREATE TABLE IF NOT EXISTS `pbl_playtest_feedback` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `user_id` VARCHAR(32) NOT NULL COMMENT "用户", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `feedback_type` VARCHAR(64) NOT NULL COMMENT "类型", + `rating` INT NOT NULL DEFAULT 0 COMMENT "评分", + `content_txt` TEXT NULL COMMENT "内容", + `context_json` LONGTEXT NULL COMMENT "上下文", + `handled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "已处理", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + KEY `idx_pf_session` (`tenant_id`, `session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Playtest 反馈/反思(reflection 走证据链外的轻量表)"; + +CREATE TABLE IF NOT EXISTS `pbl_ui_page_registry` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `page_key` VARCHAR(64) NOT NULL COMMENT "页面键", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `route` VARCHAR(64) NOT NULL COMMENT "路由", + `widget_type` VARCHAR(64) NOT NULL COMMENT "渲染组件", + `requires_role` VARCHAR(64) NOT NULL COMMENT "所需角色", + `budget_ms` INT NOT NULL DEFAULT 0 COMMENT "渲染预算", + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `meta_json` LONGTEXT NULL COMMENT "元数据", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_upr_key` (`tenant_id`, `page_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="前端覆盖层页面注册(12 页面 + 时延预算)"; + +-- ===== pbl_runtime_ext (M11) ===== +CREATE TABLE IF NOT EXISTS `pbl_runtime_event` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `event_uid` VARCHAR(32) NOT NULL COMMENT "事件UID", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `world_id` BIGINT UNSIGNED NOT NULL COMMENT "世界", + `actor_id` VARCHAR(32) NOT NULL COMMENT "触发者", + `event_type` VARCHAR(64) NOT NULL COMMENT "事件类型", + `payload_json` LONGTEXT NULL COMMENT "事件体", + `causation_id` VARCHAR(32) NOT NULL COMMENT "因果链", + `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT "状态版本", + `seq` BIGINT NOT NULL DEFAULT 0 COMMENT "会话内单调序号", + `occurred_at` DATETIME NOT NULL COMMENT "发生时间", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_re_uid` (`tenant_id`, `event_uid`, `occurred_at`), + KEY `idx_re_session_seq` (`tenant_id`, `session_id`, `seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="运行时事件(append-only,按月 RANGE 分区)", + KEY `idx_re_part` (`occurred_at`) +PARTITION BY RANGE COLUMNS(`occurred_at`) ( + PARTITION p2026 VALUES LESS THAN ('2026-01-01'), + PARTITION p2027 VALUES LESS THAN ('2027-01-01'), + PARTITION p2028 VALUES LESS THAN ('2028-01-01'), + PARTITION p2029 VALUES LESS THAN ('2029-01-01'), + PARTITION p2030 VALUES LESS THAN ('2030-01-01'), + PARTITION p2031 VALUES LESS THAN ('2031-01-01') + PARTITION pmax VALUES LESS THAN (MAXVALUE) + ); + +CREATE TABLE IF NOT EXISTS `pbl_entity_state` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `entity_id` VARCHAR(32) NOT NULL COMMENT "实体标识", + `state_json` LONGTEXT NULL COMMENT "状态", + `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT "单调版本", + `checksum` VARCHAR(32) NOT NULL COMMENT "状态摘要", + `updated_by` VARCHAR(32) NOT NULL COMMENT "写入者", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_es` (`tenant_id`, `session_id`, `entity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="实体状态(服务端权威,state_version 客户端禁写)"; + +CREATE TABLE IF NOT EXISTS `pbl_world_state_snapshot` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `snapshot_uid` VARCHAR(32) NOT NULL COMMENT "快照UID", + `state_fingerprint` VARCHAR(32) NOT NULL COMMENT "状态指纹", + `entities_json` LONGTEXT NULL COMMENT "实体集", + `events_through_seq` BIGINT NOT NULL DEFAULT 0 COMMENT "截至序号", + `reason` VARCHAR(64) NOT NULL COMMENT "触发原因", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wss_uid` (`tenant_id`, `snapshot_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="世界状态快照(掉线补齐/离线兜底)"; + +-- ===== 枚举 6+2 组由 pbl_appcodes.load_pbl_appcodes() 幂等注入 appcodes,本文件不含 INSERT ===== diff --git a/scripts/gen_artifacts.py b/scripts/gen_artifacts.py new file mode 100644 index 0000000..b12ff0b --- /dev/null +++ b/scripts/gen_artifacts.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +pbls 交付物机械生成器(单一事实源 → DDL / models / CRUD json / dspy / 模块包骨架 / RBAC 路径) + +为什么存在:QC 退回意见 #8/#9/#11 要求「表定义四段式 models/*.json」「CRUD json/*.json」 +「RBAC 路径注册」可机械核对。手写 36 表 × 3 份产物必然漂移,故用本脚本从一份 spec 生成, +保证 DDL 与 models/CRUD/RBAC 完全一致(可重复执行,幂等)。 + +方言来源:projects/pbls/env/test.json -> db.engine = mariadb(见 TYPE_MAP,禁 BIGSERIAL/SERIAL/nextval)。 +""" +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, '..', '..', '..')) # 机构工作空间根 +APP = os.path.join(ROOT, 'apps', 'pbls') +MODS = os.path.join(ROOT, 'modules') + +# ── 抽象类型 → MariaDB 10.x 物理类型映射(唯一映射表,QC #5/#8 核对用)──────────── +TYPE_MAP = { + 'pk': 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', + 'tenant': 'VARCHAR(32) NOT NULL', + 'sid': 'VARCHAR(32) NOT NULL', + 'code': 'VARCHAR(64) NOT NULL', + 'name': 'VARCHAR(128) NOT NULL', + 'txt': 'TEXT', + 'js': 'LONGTEXT', + 'int': 'INT NOT NULL DEFAULT 0', + 'bigint': 'BIGINT NOT NULL DEFAULT 0', + 'dec': 'DECIMAL(18,2) NOT NULL DEFAULT 0', + 'bool': 'TINYINT(1) NOT NULL DEFAULT 0', + 'ts': 'DATETIME NULL', + 'tsnn': 'DATETIME NOT NULL', + 'ref': 'BIGINT UNSIGNED NOT NULL', + 'sref': 'VARCHAR(32) NOT NULL', +} +ABSTRACT = { + 'pk': {'type': 'int', 'length': 20, 'unsigned': True, 'auto_increment': True}, + 'tenant': {'type': 'str', 'length': 32}, + 'sid': {'type': 'str', 'length': 32}, + 'code': {'type': 'str', 'length': 64}, + 'name': {'type': 'str', 'length': 128}, + 'txt': {'type': 'text'}, + 'js': {'type': 'json'}, + 'int': {'type': 'int'}, + 'bigint': {'type': 'bigint'}, + 'dec': {'type': 'double', 'precision': 18, 'scale': 2}, + 'bool': {'type': 'bool'}, + 'ts': {'type': 'datetime'}, + 'tsnn': {'type': 'datetime'}, + 'ref': {'type': 'bigint'}, + 'sref': {'type': 'str', 'length': 32}, +} +NULLABLE = {'txt', 'js', 'ts'} + +# ── 模块清单:编号 ↔ 目录 ↔ 表 ↔ 契约(QC #13 对账表的事实源)─────────────────── +# tables=[] 且 contracts 不含 CRUD 的模块为「零表模块」(pbl_common / pbl_appcodes) +MODULES = { + 'pbl_common': dict(no='M12', tables=[], contracts=[ + 'pbl_common_ping', 'pbl_common_audit']), + 'pbl_appcodes': dict(no='M10', tables=[], contracts=[ + 'pbl_appcodes_inject', 'pbl_appcodes_list']), + 'pbl_blueprint': dict(no='M1', tables=[ + 'pbl_blueprint', 'pbl_blueprint_learning_goal', 'pbl_blueprint_role', + 'pbl_blueprint_mission', 'pbl_blueprint_task', 'pbl_blueprint_artifact_spec', + 'pbl_blueprint_evidence_spec', 'pbl_blueprint_reflection_spec', + 'pbl_blueprint_version', 'pbl_template'], + contracts=[ + 'pbl_blueprint_create', 'pbl_blueprint_read', 'pbl_blueprint_update', + 'pbl_blueprint_delete', 'pbl_blueprint_list', 'pbl_blueprint_tree', + 'pbl_blueprint_fork', 'pbl_blueprint_subobject_save', 'pbl_blueprint_subobject_list', + 'pbl_blueprint_version_create', 'pbl_blueprint_version_diff', + 'pbl_template_list', 'pbl_template_instantiate']), + 'pbl_validation': dict(no='M2', tables=['pbl_validation_rule', 'pbl_validation_result'], + contracts=['pbl_validation_run', 'pbl_validation_get', 'pbl_validation_list', + 'pbl_validation_rule_list', 'pbl_validation_rule_save']), + 'pbl_compiler': dict(no='M3', tables=['pbl_compiler_version', 'pbl_game_definition', + 'pbl_capability_registry'], + contracts=['pbl_compiler_compile', 'pbl_compiler_preview', 'pbl_compiler_compare', + 'pbl_compiler_version_list', 'pbl_compiler_version_save', + 'pbl_capability_list', 'pbl_capability_register']), + 'pbl_agent_runtime': dict(no='M4', tables=['pbl_agent_def', 'pbl_tool_registry', + 'pbl_agent_trace', 'pbl_agent_tool_call', 'pbl_approval', 'pbl_llm_call_log'], + contracts=['pbl_agent_designer_run', 'pbl_agent_critic_run', 'pbl_agent_trace_write', + 'pbl_agent_trace_list', 'pbl_tool_registry_list', 'pbl_tool_registry_save', + 'pbl_tool_adjudicate', 'pbl_approval_create', 'pbl_approval_decide', + 'pbl_approval_list']), + 'pbl_evidence': dict(no='M5', tables=['pbl_artifact', 'pbl_evidence'], + contracts=['pbl_artifact_create', 'pbl_artifact_read', 'pbl_artifact_update', + 'pbl_artifact_delete', 'pbl_artifact_list', 'pbl_evidence_collect', + 'pbl_evidence_list']), + 'pbl_assessment': dict(no='M6', tables=['pbl_rubric', 'pbl_assessment_record'], + contracts=['pbl_rubric_save', 'pbl_rubric_get', 'pbl_rubric_list', + 'pbl_assessment_score', 'pbl_assessment_record_get', 'pbl_assessment_report']), + 'pbl_kdb_ext': dict(no='M7', tables=['pbl_kdb_item', 'pbl_kdb_query_log'], + contracts=['pbl_kdb_search', 'pbl_kdb_get', 'pbl_research_aggregate']), + 'pbl_domain_ext': dict(no='M8', tables=['pbl_tenant', 'pbl_class', 'pbl_team'], + contracts=['pbl_tenant_upsert', 'pbl_class_save', 'pbl_class_list', + 'pbl_team_save', 'pbl_team_list', + 'pbl_domain_materialize_game_definition']), + 'pbl_scense_ext': dict(no='M9', tables=['pbl_session_member', 'pbl_playtest_feedback', + 'pbl_ui_page_registry'], + contracts=['pbl_session_context_get', 'pbl_page_registry_list', + 'pbl_playtest_feedback_save', 'pbl_overlay_bootstrap']), + 'pbl_runtime_ext': dict(no='M11', tables=['pbl_runtime_event', 'pbl_entity_state', + 'pbl_world_state_snapshot'], + contracts=['pbl_runtime_event_append', 'pbl_runtime_event_poll', + 'pbl_entity_state_get', 'pbl_entity_state_apply', + 'pbl_world_broadcast', 'pbl_session_member_add']), +} + +# ── 36 表定义(字段: (name, 抽象类型, 注释[, null]))──────────────────────────── +T = {} + + +def tbl(mod, name, comment, fields, indexes, codes=None, partition_month=None): + T[name] = dict(module=mod, comment=comment, fields=fields, indexes=indexes, + codes=codes or [], partition_month=partition_month) + + +tbl('pbl_blueprint', 'pbl_blueprint', 'PBL 蓝图聚合根(第8章 schema 落库)', [ + ('code', 'code', '蓝图编码'), ('title', 'name', '标题'), ('subject', 'code', '学科'), + ('grade', 'code', '年级'), ('description', 'txt', '说明'), + ('quality_state', 'code', '质量状态 pbl_quality_state'), + ('generation_source', 'code', '生成来源 llm/template_fallback/manual'), + ('approval_status', 'code', '审批状态'), ('visibility', 'code', '可见范围'), + ('content_json', 'js', '蓝图正文(结构化模型,非散文)'), + ('version_no', 'int', '当前版本'), ('compiler_version_id', 'ref', '编译版本', True), + ('world_id', 'ref', '编译产物落 world 的溯源ID', True), + ('created_by', 'sref', '创建人')], + [('uk_bp_tenant_code', ['tenant_id', 'code'], True), + ('idx_bp_tenant_state', ['tenant_id', 'quality_state'], False), + ('idx_bp_tenant_creator', ['tenant_id', 'created_by'], False)], + [('quality_state', 'pbl_quality_state'), ('approval_status', 'pbl_approval_status'), + ('visibility', 'pbl_visibility')]) + +SUB = [('blueprint_id', 'ref', '所属蓝图'), ('obj_uid', 'sid', '子对象UID')] +for mod_tbl, extra, cmt in [ + ('pbl_blueprint_learning_goal', [('code', 'code', '目标编码'), ('title', 'name', '标题'), + ('description', 'txt', '描述'), ('bloom_level', 'code', '布鲁姆层级'), + ('measurable', 'bool', '是否可测'), ('order_no', 'int', '排序')], '学习目标'), + ('pbl_blueprint_role', [('code', 'code', '角色编码'), ('title', 'name', '角色名'), + ('persona_txt', 'txt', '人设'), ('capabilities_json', 'js', '能力引用'), + ('headcount', 'int', '名额'), ('order_no', 'int', '排序')], 'PBL 角色'), + ('pbl_blueprint_mission', [('code', 'code', '任务域编码'), ('title', 'name', '驱动问题域'), + ('scenario_txt', 'txt', '情境'), ('unlock_condition_json', 'js', '解锁条件'), + ('order_no', 'int', '排序')], '驱动问题/Mission'), + ('pbl_blueprint_task', [('mission_id', 'ref', '所属Mission'), ('code', 'code', '编码'), + ('title', 'name', '任务名'), ('steps_json', 'js', '步骤'), + ('difficulty', 'code', '难度'), ('order_no', 'int', '排序')], '任务'), + ('pbl_blueprint_artifact_spec', [('code', 'code', '编码'), ('title', 'name', '产出物名'), + ('artifact_type', 'code', '产出类型'), ('required_fields_json', 'js', '必填字段'), + ('order_no', 'int', '排序')], '产出物规格'), + ('pbl_blueprint_evidence_spec', [('evidence_type', 'code', '证据类型'), + ('source', 'code', '来源'), ('min_count', 'int', '最少条数'), + ('rubric_criterion_uid', 'sid', '对齐的评分准则UID', True), + ('order_no', 'int', '排序')], '证据规格')]: + tbl('pbl_blueprint', mod_tbl, '蓝图子对象 - ' + cmt, SUB + extra, + [('idx_%s_bp' % mod_tbl[4:], ['tenant_id', 'blueprint_id'], False)]) + +tbl('pbl_blueprint', 'pbl_blueprint_reflection_spec', '蓝图子对象 - 反思规格', + SUB + [('trigger', 'code', '触发点'), ('prompt_txt', 'txt', '反思引导语'), + ('min_words', 'int', '最少字数'), ('evidence_type', 'code', '产出证据类型'), + ('order_no', 'int', '排序')], + [('idx_bp_rs_bp', ['tenant_id', 'blueprint_id'], False)]) + +tbl('pbl_blueprint', 'pbl_blueprint_version', '蓝图版本(change_delta 证明对话式改模型)', [ + ('blueprint_id', 'ref', '蓝图ID'), ('version_no', 'int', '版本号'), + ('snapshot_json', 'js', '全量快照'), ('change_delta_json', 'js', '结构化增量'), + ('quality_state', 'code', '质量状态'), ('generation_source', 'code', '生成来源'), + ('created_by', 'sref', '操作人')], + [('uk_bpv', ['tenant_id', 'blueprint_id', 'version_no'], True)]) + +tbl('pbl_blueprint', 'pbl_template', 'PBL 模板(含离线兜底槽位)', [ + ('code', 'code', '模板编码'), ('title', 'name', '标题'), ('subject', 'code', '学科'), + ('grade', 'code', '年级'), ('skeleton_json', 'js', '骨架'), + ('fallback_slot_json', 'js', '兜底槽位(LLM不可用时填充)'), + ('is_builtin', 'bool', '内置'), ('use_count', 'int', '实例化次数'), + ('quality_state', 'code', '质量状态')], + [('uk_tpl_tenant_code', ['tenant_id', 'code'], True)]) + +tbl('pbl_validation', 'pbl_validation_rule', '校验规则(14 维,规则 JSON 形态 script_type=1)', [ + ('code', 'code', '规则编码'), ('dimension', 'code', '维度'), ('title', 'name', '标题'), + ('rule_json', 'js', '规则体'), ('threshold_json', 'js', '阈值(可配,Q-OPEN-8)'), + ('severity', 'code', '严重度'), ('enabled', 'bool', '启用'), ('version_no', 'int', '版本')], + [('uk_vr_code', ['tenant_id', 'code', 'version_no'], True)]) + +tbl('pbl_validation', 'pbl_validation_result', '校验结果(14 维 + 5 级质量状态)', [ + ('blueprint_id', 'ref', '蓝图ID'), ('blueprint_version_no', 'int', '校验时版本'), + ('dimension_count', 'int', '维度数(=14)'), ('passed_count', 'int', '通过数'), + ('failed_count', 'int', '失败数'), ('score', 'dec', '得分'), + ('quality_state', 'code', '质量状态'), ('findings_json', 'js', '逐维结论'), + ('rule_set_version', 'code', '规则集版本'), ('created_by', 'sref', '触发人')], + [('idx_vres_bp', ['tenant_id', 'blueprint_id'], False)]) + +tbl('pbl_compiler', 'pbl_compiler_version', '编译器版本(29.6 确定性)', [ + ('code', 'code', '版本编码'), ('semver', 'code', '语义化版本'), + ('rules_hash', 'sid', '规则集SHA256前缀'), ('entrypoint', 'code', '入口函数'), + ('enabled', 'bool', '启用'), ('notes', 'txt', '说明')], + [('uk_cv_code', ['tenant_id', 'code'], True)]) + +tbl('pbl_compiler', 'pbl_game_definition', 'Game Definition(第9章 schema + 指纹)', [ + ('blueprint_id', 'ref', '来源蓝图'), ('blueprint_version_no', 'int', '蓝图版本'), + ('compiler_version_id', 'ref', '编译版本'), ('content_fingerprint', 'sid', 'SHA-256 指纹'), + ('definition_json', 'js', 'Game Definition 正文'), + ('world_id', 'ref', '落库 world', True), ('scene_id', 'ref', '落库 scene', True), + ('entity_count', 'int', '实体数'), ('event_count', 'int', '事件数'), + ('quality_state', 'code', '编译时质量状态'), ('duration_ms', 'int', '耗时')], + [('uk_gd_fp', ['tenant_id', 'content_fingerprint'], True), + ('idx_gd_bp', ['tenant_id', 'blueprint_id'], False)]) + +tbl('pbl_compiler', 'pbl_capability_registry', 'Capability Registry(第11章缺口补齐)', [ + ('capability_key', 'code', '能力键'), ('category', 'code', '分类'), + ('args_schema_json', 'js', '参数JSON Schema'), ('permission_required', 'code', '所需权限'), + ('is_enabled', 'bool', '启用'), ('version_no', 'int', '版本'), ('description', 'txt', '说明')], + [('uk_cap_key', ['tenant_id', 'capability_key', 'version_no'], True)]) + +tbl('pbl_agent_runtime', 'pbl_agent_def', 'Agent 定义(Designer/Critic)', [ + ('code', 'code', 'Agent编码'), ('agent_type', 'code', '类型'), ('name', 'name', '名称'), + ('model_route', 'code', 'pipeline-llm 路由'), ('system_prompt_txt', 'txt', '提示词'), + ('tool_whitelist_json', 'js', '工具白名单'), ('permission_mode', 'code', 'read/write 模式'), + ('is_enabled', 'bool', '启用'), ('version_no', 'int', '版本')], + [('uk_ad_code', ['tenant_id', 'code'], True)]) + +tbl('pbl_agent_runtime', 'pbl_tool_registry', '工具注册表(13 启用 / 9 禁用含 pbl.publish)', [ + ('tool_key', 'code', '工具键'), ('title', 'name', '标题'), ('agent_scope', 'code', '作用域'), + ('permission_required', 'code', '所需权限'), ('enabled', 'bool', '启用'), + ('params_schema_json', 'js', '参数白名单Schema'), ('disabled_reason', 'code', '禁用原因', True), + ('version_no', 'int', '版本')], + [('uk_tr_key', ['tenant_id', 'tool_key'], True)]) + +tbl('pbl_agent_runtime', 'pbl_agent_trace', 'Agent 执行轨迹(第28章 7 要素)', [ + ('trace_uid', 'sid', '轨迹UID'), ('agent_code', 'code', 'Agent'), + ('blueprint_id', 'ref', '目标蓝图', True), ('input_ref_json', 'js', '要素1 输入引用'), + ('thought_txt', 'txt', '要素2 思考'), ('action_code', 'code', '要素3 动作'), + ('action_params_json', 'js', '要素4 动作参数'), ('observation_json', 'js', '要素5 观察'), + ('result_state', 'code', '要素6 结果状态'), ('llm_calls', 'int', '要素7 LLM调用数'), + ('duration_ms', 'int', '耗时'), ('created_by', 'sref', '触发用户')], + [('uk_at_uid', ['tenant_id', 'trace_uid'], True), + ('idx_at_tenant', ['tenant_id', 'created_at'], False)]) + +tbl('pbl_agent_runtime', 'pbl_agent_tool_call', '工具调用明细(服务端裁决留痕)', [ + ('trace_id', 'ref', '轨迹ID'), ('tool_key', 'code', '工具键'), + ('request_json', 'js', '请求'), ('response_json', 'js', '响应'), + ('ok', 'bool', '成功'), ('error_code', 'code', '错误码', True), + ('adjudication_code', 'code', '裁决结论'), ('duration_ms', 'int', '耗时')], + [('idx_tc_trace', ['tenant_id', 'trace_id'], False)]) + +tbl('pbl_agent_runtime', 'pbl_approval', '人工审批单(14.2 四类,无绕过路径)', [ + ('approval_uid', 'sid', '审批UID'), ('approval_type', 'code', '类型'), + ('target_type', 'code', '对象类型'), ('target_id', 'ref', '对象ID'), + ('payload_json', 'js', '变更内容'), ('status', 'code', '状态'), + ('requested_by', 'sref', '发起人'), ('decided_by', 'sref', '决策人', True), + ('decided_at', 'ts', '决策时间'), ('comment_txt', 'txt', '意见')], + [('uk_ap_uid', ['tenant_id', 'approval_uid'], True), + ('idx_ap_status', ['tenant_id', 'status'], False)], + [('status', 'pbl_approval_status')]) + +tbl('pbl_agent_runtime', 'pbl_llm_call_log', 'LLM 调用日志(超时/重试/限流/兜底留痕)', [ + ('call_uid', 'sid', '调用UID'), ('agent_code', 'code', 'Agent'), ('route', 'code', '路由'), + ('model', 'code', '实际模型', True), ('prompt_hash', 'sid', '提示词哈希'), + ('completion_tokens', 'int', 'tokens'), ('latency_ms', 'int', '时延'), + ('status', 'code', '状态'), ('error_code', 'code', '错误码', True), + ('fallback_used', 'bool', '走兜底')], + [('uk_lc_uid', ['tenant_id', 'call_uid'], True)]) + +tbl('pbl_evidence', 'pbl_artifact', '学生产出物(版本内联 version_no)', [ + ('artifact_uid', 'sid', '产出UID'), ('session_id', 'ref', '会话'), + ('blueprint_id', 'ref', '蓝图'), ('team_id', 'ref', '团队', True), + ('creator_id', 'sref', '作者'), ('title', 'name', '标题'), + ('artifact_type', 'code', '类型'), ('content_json', 'js', '内容'), + ('version_no', 'int', '版本'), ('status', 'code', '状态'), ('submitted_at', 'ts', '提交时间')], + [('uk_art_uid', ['tenant_id', 'artifact_uid'], True), + ('idx_art_session', ['tenant_id', 'session_id'], False)], + [('artifact_type', 'pbl_artifact_type')]) + +tbl('pbl_evidence', 'pbl_evidence', '学习证据(幂等采集,唯一索引防重)', [ + ('artifact_id', 'ref', '产出物', True), ('evidence_type', 'code', '证据类型'), + ('source_event_id', 'sid', '来源 runtime_event'), ('session_id', 'ref', '会话'), + ('learner_id', 'sref', '学习者'), ('blueprint_id', 'ref', '蓝图', True), + ('payload_json', 'js', '证据体'), ('occurred_at', 'tsnn', '发生时间'), + ('dedup_key', 'sid', '去重键')], + [('uk_ev_dedup', ['tenant_id', 'source_event_id', 'evidence_type'], True), + ('idx_ev_learner', ['tenant_id', 'learner_id'], False)], + [('evidence_type', 'pbl_evidence_type')]) + +tbl('pbl_assessment', 'pbl_rubric', 'Rubric(准则内联 criteria_json,权重合计=100%)', [ + ('code', 'code', '编码'), ('title', 'name', '标题'), ('blueprint_id', 'ref', '蓝图', True), + ('criteria_json', 'js', '准则数组(含 learning_goal_id/weight)'), + ('total_weight', 'dec', '权重合计'), ('version_no', 'int', '版本'), + ('status', 'code', '状态'), ('approval_id', 'ref', '变更审批单', True)], + [('uk_ru_code', ['tenant_id', 'code', 'version_no'], True)]) + +tbl('pbl_assessment', 'pbl_assessment_record', '评估记录(total_score 派生 + 对齐判定)', [ + ('record_uid', 'sid', '记录UID'), ('learner_id', 'sref', '学习者'), + ('session_id', 'ref', '会话', True), ('blueprint_id', 'ref', '蓝图', True), + ('artifact_id', 'ref', '产出物', True), ('evidence_id', 'ref', '主证据', True), + ('rubric_id', 'ref', 'Rubric'), ('scores_json', 'js', '逐准则得分'), + ('total_score', 'dec', '加权总分'), ('max_score', 'dec', '满分'), + ('band', 'code', '等级'), ('alignment_json', 'js', '与学习目标对齐'), + ('feedback_txt', 'txt', '反馈'), ('assessor_type', 'code', '评估者类型'), + ('assessor_id', 'sref', '评估者')], + [('uk_ar_uid', ['tenant_id', 'record_uid'], True), + ('idx_ar_learner', ['tenant_id', 'learner_id'], False)]) + +tbl('pbl_kdb_ext', 'pbl_kdb_item', 'KDB 条目(建表不写入,is_write_locked 恒 1)', [ + ('code', 'code', '条目编码'), ('item_type', 'code', '类型'), ('title', 'name', '标题'), + ('summary_txt', 'txt', '摘要'), ('payload_json', 'js', '内容(无向量,Q-OPEN-5)'), + ('source', 'code', '来源'), ('is_write_locked', 'bool', '写锁(恒1)'), + ('status', 'code', '状态'), ('version_no', 'int', '版本')], + [('uk_kdb_code', ['tenant_id', 'code'], True)]) + +tbl('pbl_kdb_ext', 'pbl_kdb_query_log', 'KDB 检索日志(只读侧留痕)', [ + ('query_uid', 'sid', '查询UID'), ('caller_type', 'code', '调用方类型'), + ('caller_id', 'sref', '调用方'), ('query_json', 'js', '查询条件'), + ('hit_count', 'int', '命中数'), ('latency_ms', 'int', '时延'), ('ok', 'bool', '成功')], + [('uk_kql_uid', ['tenant_id', 'query_uid'], True)]) + +tbl('pbl_domain_ext', 'pbl_tenant', '租户(多租户边界第25章,不改基表)', [ + ('tenant_uid', 'sid', '租户UID'), ('name', 'name', '名称'), ('kind', 'code', '类型'), + ('plan', 'code', '套餐'), ('seat_limit', 'int', '席位'), ('status', 'code', '状态'), + ('config_json', 'js', '配置')], + [('uk_ten_uid', ['tenant_id', 'tenant_uid'], True)]) + +tbl('pbl_domain_ext', 'pbl_class', '班级(花名册内联 roster_json)', [ + ('class_uid', 'sid', '班级UID'), ('name', 'name', '名称'), ('grade', 'code', '年级'), + ('teacher_id', 'sref', '教师'), ('student_count', 'int', '学生数'), + ('roster_json', 'js', '花名册'), ('status', 'code', '状态')], + [('uk_cls_uid', ['tenant_id', 'class_uid'], True)]) + +tbl('pbl_domain_ext', 'pbl_team', '团队(成员内联 members_json,不改基表)', [ + ('team_uid', 'sid', '团队UID'), ('class_id', 'ref', '班级'), ('name', 'name', '名称'), + ('mission_id', 'ref', 'Mission', True), ('members_json', 'js', '成员'), + ('capacity', 'int', '容量'), ('status', 'code', '状态'), + ('session_id', 'ref', '绑定会话', True)], + [('uk_team_uid', ['tenant_id', 'team_uid'], True), + ('idx_team_class', ['tenant_id', 'class_id'], False)]) + +tbl('pbl_scense_ext', 'pbl_session_member', '会话成员(复用 world_session,不污染基表)', [ + ('session_id', 'ref', '会话'), ('user_id', 'sref', '用户'), ('role_code', 'code', '角色'), + ('team_id', 'ref', '团队', True), ('joined_at', 'ts', '加入时间'), + ('last_seen_at', 'ts', '心跳'), ('state_version', 'bigint', '已见状态版本'), + ('status', 'code', '状态')], + [('uk_sm', ['tenant_id', 'session_id', 'user_id'], True)]) + +tbl('pbl_scense_ext', 'pbl_playtest_feedback', 'Playtest 反馈/反思(reflection 走证据链外的轻量表)', [ + ('session_id', 'ref', '会话', True), ('user_id', 'sref', '用户'), + ('blueprint_id', 'ref', '蓝图', True), ('feedback_type', 'code', '类型'), + ('rating', 'int', '评分'), ('content_txt', 'txt', '内容'), + ('context_json', 'js', '上下文'), ('handled', 'bool', '已处理')], + [('idx_pf_session', ['tenant_id', 'session_id'], False)]) + +tbl('pbl_scense_ext', 'pbl_ui_page_registry', '前端覆盖层页面注册(12 页面 + 时延预算)', [ + ('page_key', 'code', '页面键'), ('title', 'name', '标题'), ('route', 'code', '路由'), + ('widget_type', 'code', '渲染组件'), ('requires_role', 'code', '所需角色'), + ('budget_ms', 'int', '渲染预算'), ('enabled', 'bool', '启用'), ('meta_json', 'js', '元数据')], + [('uk_upr_key', ['tenant_id', 'page_key'], True)]) + +tbl('pbl_runtime_ext', 'pbl_runtime_event', '运行时事件(append-only,按月 RANGE 分区)', [ + ('event_uid', 'sid', '事件UID'), ('session_id', 'ref', '会话'), + ('world_id', 'ref', '世界'), ('actor_id', 'sref', '触发者'), + ('event_type', 'code', '事件类型'), ('payload_json', 'js', '事件体'), + ('causation_id', 'sid', '因果链', True), ('state_version', 'bigint', '状态版本'), + ('seq', 'bigint', '会话内单调序号'), ('occurred_at', 'tsnn', '发生时间')], + [('uk_re_uid', ['tenant_id', 'event_uid', 'occurred_at'], True), + ('idx_re_session_seq', ['tenant_id', 'session_id', 'seq'], False)], + [('event_type', 'pbl_event_type')], partition_month=True) + +tbl('pbl_runtime_ext', 'pbl_entity_state', '实体状态(服务端权威,state_version 客户端禁写)', [ + ('session_id', 'ref', '会话'), ('entity_id', 'sid', '实体标识'), + ('state_json', 'js', '状态'), ('state_version', 'bigint', '单调版本'), + ('checksum', 'sid', '状态摘要'), ('updated_by', 'sref', '写入者')], + [('uk_es', ['tenant_id', 'session_id', 'entity_id'], True)]) + +tbl('pbl_runtime_ext', 'pbl_world_state_snapshot', '世界状态快照(掉线补齐/离线兜底)', [ + ('session_id', 'ref', '会话'), ('snapshot_uid', 'sid', '快照UID'), + ('state_fingerprint', 'sid', '状态指纹'), ('entities_json', 'js', '实体集'), + ('events_through_seq', 'bigint', '截至序号'), ('reason', 'code', '触发原因')], + [('uk_wss_uid', ['tenant_id', 'snapshot_uid'], True)]) + +assert len(T) == 36, '表数应为 36,实际 %d' % len(T) + +# ── 枚举 6+2 组(appcodes.md 幂等注入,零表模块 pbl_appcodes 的事实源)──────────── +APPCODES = { + 'pbl_quality_state': ['draft', 'needs_work', 'pbl_ready', 'playtest_ready', 'publish_ready'], + 'pbl_approval_status': ['none', 'pending', 'approved', 'rejected', 'withdrawn'], + 'pbl_visibility': ['private', 'class', 'school', 'public_readonly'], + 'pbl_evidence_type': ['artifact', 'process_event', 'reflection', 'feedback'], + 'pbl_agent_type': ['designer', 'critic'], + 'pbl_tool_state': ['enabled', 'disabled_contract_only'], + 'pbl_artifact_type': ['document', 'presentation', 'model', 'code', 'media'], + 'pbl_event_type': ['mission.completed', 'task.completed', 'pbl.unlockMission', + 'entity.state.changed', 'session.member.joined', 'artifact.submitted'], +} + +W = [] + + +def w(path, content): + full = os.path.join(os.path.dirname(path) and path.rsplit(os.sep, 1)[0], '') + os.makedirs(os.path.dirname(path), exist_ok=True) + old = None + if os.path.exists(path): + with open(path, encoding='utf-8') as f: + old = f.read() + if old != content: + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + W.append(os.path.relpath(path, ROOT)) + + +def ddl_of(name): + spec = T[name] + cols = [' `tenant_id` %s NOT NULL COMMENT %s' % (TYPE_MAP['tenant'], '租户ID(强制打头)'), + ' `id` %s COMMENT %s' % (TYPE_MAP['pk'], '主键')] + for f in spec['fields']: + fn, ft, cmt = f[0], f[1], f[2] + nn = f[3] if len(f) > 3 else ft not in NULLABLE + cols.append(' `%s` %s%s COMMENT %s' % (fn, TYPE_MAP[ft], '' if nn else ' NULL', + json.dumps(cmt, ensure_ascii=False))) + # 时间列一律 DATETIME(不使用 时间戳类型),值由应用层 pbl_common.api.now_str() 写入, + # 避免隐式默认带来的时区漂移;默认值为纪元占位(非 NULL,便于排序与增量拉取)。 + cols.append(' `created_at` DATETIME NOT NULL DEFAULT \'1970-01-01 00:00:00\' COMMENT %s' + % json.dumps('创建时间(应用层写入)', ensure_ascii=False)) + cols.append(' `updated_at` DATETIME NOT NULL DEFAULT \'1970-01-01 00:00:00\' COMMENT %s' + % json.dumps('更新时间(应用层写入)', ensure_ascii=False)) + cols.append(' PRIMARY KEY (`id`)') + for iname, icols, uniq in spec['indexes']: + cols.append(' %s `%s` (%s)' % ('UNIQUE KEY' if uniq else 'KEY', iname, + ', '.join('`%s`' % c for c in icols))) + body = ',\n'.join(cols) + sql = 'CREATE TABLE IF NOT EXISTS `%s` (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' % (name, body) + sql += " COLLATE=utf8mb4_general_ci COMMENT=%s" % json.dumps(spec['comment'], ensure_ascii=False) + if spec['partition_month']: + parts = ',\n'.join(" PARTITION p%s VALUES LESS THAN ('%s')" % (y, '%d-01-01' % y) + for y in range(2026, 2032)) + sql += (',\n KEY `idx_re_part` (`occurred_at`)\nPARTITION BY RANGE COLUMNS(`occurred_at`)' + ' (\n%s\n PARTITION pmax VALUES LESS THAN (MAXVALUE)\n )' % parts) + return sql + ';\n' + + +def model_json(name): + spec = T[name] + fields = [{'name': 'tenant_id', 'comment': '租户ID(强制打头)', 'null': False, **ABSTRACT['tenant']}, + {'name': 'id', 'comment': '主键', 'null': False, **ABSTRACT['pk']}] + for f in spec['fields']: + fn, ft, cmt = f[0], f[1], f[2] + nn = f[3] if len(f) > 3 else ft not in NULLABLE + fields.append({'name': fn, 'comment': cmt, 'null': not nn, **ABSTRACT[ft]}) + fields.append({'name': 'created_at', 'comment': '创建时间', 'null': False, **ABSTRACT['tsnn']}) + fields.append({'name': 'updated_at', 'comment': '更新时间', 'null': False, **ABSTRACT['tsnn']}) + doc = { + 'summary': [{'name': name, 'comment': spec['comment'], 'module': spec['module'], + 'engine': 'mariadb', 'charset': 'utf8mb4', 'tenant_scoped': True}], + 'fields': fields, + 'indexes': [{'name': i[0], 'unique': i[2], 'fields': i[1]} for i in spec['indexes']] + + [{'name': 'PRIMARY', 'unique': True, 'fields': ['id']}], + 'codes': [{'field': c[0], 'appcode': c[1]} for c in spec['codes']], + } + return json.dumps(doc, ensure_ascii=False, indent=2) + '\n' + + +def crud_json(mod, name): + spec = T[name] + br, ed = {}, {} + for f in spec['fields']: + fn, ft, cmt = f[0], f[1], f[2] + ui = {'label': cmt or fn, 'type': 'text' if ft in ('txt', 'js', 'sid', 'sref') else + 'number' if ft in ('int', 'bigint', 'dec', 'ref') else + 'checkbox' if ft == 'bool' else 'date' if ft.startswith('ts') else 'select'} + if ui['type'] == 'date': + ui['type'] = 'text' + br[fn] = dict(ui, list=True) + if ft != 'pk': + ed[fn] = ui + br = dict({'id': {'label': 'ID', 'list': True, 'type': 'number'}, + 'tenant_id': {'label': '租户ID', 'list': True, 'type': 'text'}}, **br) + doc = {'tblname': name, 'alias': mod, 'browserfields': br, + 'params': {'browserfields': br, 'editable': ed, + 'order_by': 'id DESC', 'page_size': 20}} + return json.dumps(doc, ensure_ascii=False, indent=2) + '\n' + + +DSPY = """# {mod}/api/{fn}.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('{mod}/api/{fn}.dspy: START params_kw={{dict(params_kw)}}') +data = await {fn}(**params_kw) +return data +""" + +INIT_PY = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_{mod}()` —— {mod} 模块唯一挂载入口(自动生成)。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from {mod}.api import ( +{imports} +) + + +def load_{mod}(): + env = ServerEnv() + env.get_module_dbname = env.get_module_dbname # 库名由应用决定,模块不硬编码 +{registers} + return '{mod}' +''' + +DYN_INIT_PY = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_{mod}()` —— {mod} 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from {mod}.api import ( +{imports} +) + + +def load_{mod}(): + env = ServerEnv() +{registers} + return '{mod}' +''' + +PKG_INIT_PY = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""{mod} —— {desc} + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from {mod}.init import load_{mod} +from {mod}.api import ( +{imports} +) + +__all__ = [ + 'load_{mod}', +{exports} +] +''' + +PYPROJECT = '''[project] +name = "{mod}" +version = "0.1.0" +description = "{desc}" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["{mod}"] +''' + +LOAD_PATH = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""{mod} RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/{mod}/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = '{mod}' + +# (path, role) +PATHS = [ +{entries} +] + + +def register(): + tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py') + done, missing = 0, [] + for path, role in PATHS: + if subprocess.call([sys.executable if os.environ.get('PY') else 'python3', + tool, role, path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + done += 1 + else: + missing.append((path, role)) + print('[%s] rbac paths: total=%%d ok=%%d pending=%%d' %% (len(PATHS), done, len(missing))) + for path, role in missing: + print(' PENDING %%-12s %%s' %% (role, path)) + return len(missing) == 0 + + +if __name__ == '__main__': + sys.exit(0 if register() else 1) +''' + +SKILL_MD = '''# {mod} 模块技能(自动生成骨架 + 人工补充) + +## 定位 +{desc} + +## 挂载 +`from {mod}.init import load_{mod}` → `load_{mod}()`(应用 app/pbls.py init() 中按序调用) + +## 数据表({tn} 张) +{tables} + +## 契约接口({cn} 个,路径 `/{mod}/api/.dspy`) +{contracts} + +## 陷阱 +- 库名一律 `ServerEnv().get_module_dbname('{mod}')`,禁止硬编码 DBNAME。 +- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 pbl_common.api 的 q_all/q_one(已适配)。 +- 所有读写强制带 `tenant_id`(pbl_common.api.tenant_id()),缺失即 fail-closed 报错。 +- 新增契约需同步三处:api.py 定义 + __init__.py 导出 + init.py env 注册 + scripts/load_path.py 路径。 +''' + +INDEX_UI = json.dumps({ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "20px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "{title}", "fontSize": "24px"}}, + {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, + "subwidgets": []}, + {"widgettype": "VBox", "id": "{mod}_content", + "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}] +}, ensure_ascii=False, indent=2) + '\n' + + +def contracts_of(mod): + return MODULES[mod]['contracts'] + + +def write_module(mod, desc): + spec = MODULES[mod] + base = os.path.join(MODS, mod) + pkg = os.path.join(base, mod) + cs = contracts_of(mod) + imports = ''.join(" %s,\n" % c for c in cs) + registers = ''.join(" env.%s = %s\n" % (c, c) for c in cs) + exports = ''.join(" %r,\n" % c for c in cs) + w(os.path.join(pkg, 'init.py'), DYN_INIT_PY.format( + mod=mod, imports=imports, registers=registers)) + w(os.path.join(pkg, '__init__.py'), PKG_INIT_PY.format( + mod=mod, desc=desc, imports=imports, exports=exports)) + w(os.path.join(base, 'pyproject.toml'), PYPROJECT.format(mod=mod, desc=desc)) + entries = ''.join(" ('/%s/api/%s.dspy', %r),\n" % (mod, c, 'logined') for c in cs) + w(os.path.join(base, 'scripts', 'load_path.py'), LOAD_PATH.format( + mod=mod, entries=entries)) + w(os.path.join(base, 'skill', 'SKILL.md'), SKILL_MD.format( + mod=mod, desc=desc, tn=len(spec['tables']), + tables='\n'.join('- `%s`:%s' % (t, T[t]['comment']) for t in spec['tables']) or '- 无(零表模块)', + cn=len(cs), contracts='\n'.join('- `%s`' % c for c in cs))) + cards = [] + for c in cs: + cards.append({ + "widgettype": "VBox", + "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "app.%s_content" % mod, + "options": {"url": "{{entire_url('api/%s.dspy')}}" % c}, "mode": "replace"}], + "subwidgets": [{"widgettype": "Text", "options": {"label": c}}]}) + ui = json.loads(INDEX_UI.replace('{title}', desc).replace('{mod}', mod)) + ui['subwidgets'][1]['subwidgets'] = cards + w(os.path.join(base, 'wwwroot', 'index.ui'), + json.dumps(ui, ensure_ascii=False, indent=2) + '\n') + for c in cs: + w(os.path.join(base, 'wwwroot', 'api', c + '.dspy'), DSPY.format(mod=mod, fn=c)) + for t in spec['tables']: + w(os.path.join(base, 'models', t + '.json'), model_json(t)) + w(os.path.join(base, 'json', '%s_%s.json' % (mod[4:], t)), crud_json(mod, t)) + + +DESCS = { + 'pbl_common': 'PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)', + 'pbl_appcodes': 'PBL 枚举编码注入(6+2 组,幂等,零表)', + 'pbl_blueprint': 'PBL 蓝图聚合根与子对象、版本、模板(M1a/M1b)', + 'pbl_validation': 'PBL 校验引擎(14 维 + 5 级质量状态,M2)', + 'pbl_compiler': 'PBL Compiler v1(确定性编译 + Game Definition,M3a/M3b)', + 'pbl_agent_runtime': 'Designer/Critic Agent 运行时与 fail-closed 工具裁决(M4a/M4b)', + 'pbl_evidence': '产出物与证据幂等采集(M5a/M5b)', + 'pbl_assessment': 'Rubric 加权评估与报告(M6)', + 'pbl_kdb_ext': 'KDB 只读桩与匿名聚合(M7,零写入)', + 'pbl_domain_ext': 'world/scene/entity 薄扩展:租户/班级/团队关联(M8,不改基表)', + 'pbl_scense_ext': 'scense/scense_game 前端覆盖层与页面注册(M9)', + 'pbl_runtime_ext': 'scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b)', +} + +for m in MODULES: + write_module(m, DESCS[m]) + +# ── 应用级 DDL(36 表,按模块顺序)────────────────────────────────────────── +order = ['pbl_blueprint', 'pbl_validation', 'pbl_compiler', 'pbl_agent_runtime', + 'pbl_evidence', 'pbl_assessment', 'pbl_kdb_ext', 'pbl_domain_ext', + 'pbl_scense_ext', 'pbl_runtime_ext'] +parts = ["-- pbls 36 表 DDL(MariaDB 10.x 方言,由 scripts/gen_artifacts.py 从同一 spec 生成)", + "-- 硬约束:tenant_id 打头 / id 为 BIGINT UNSIGNED AUTO_INCREMENT 主键;" + "-- 不使用 外键约束 / 枚举列类型 / 时间戳列类型 / 位图列类型(枚举值走 appcodes 编码表," + "时间列走 DATETIME 由应用层写入)", + "-- 方言来源:projects/pbls/env/test.json -> db.engine = mariadb" + "(禁 BIGSERIAL/SERIAL/nextval 等 PG 语法)", + "SET NAMES utf8mb4;", ""] +n = 0 +for mod in order: + parts.append('-- ===== %s (%s) =====' % (mod, MODULES[mod]['no'])) + for t in MODULES[mod]['tables']: + parts.append(ddl_of(t)) + n += 1 +parts.append('-- ===== 枚举 6+2 组由 pbl_appcodes.load_pbl_appcodes() 幂等注入 appcodes,本文件不含 INSERT =====') +w(os.path.join(APP, 'scripts', 'ddl', 'pbls_tables.sql'), '\n'.join(parts) + '\n') +assert n == 36, n + +# 分模块 DDL 片段(模块仓库自包含,便于单模块重建表) +for mod in order: + frag = ['-- %s 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源)' % mod] + for t in MODULES[mod]['tables']: + frag.append(ddl_of(t)) + w(os.path.join(MODS, mod, 'sql', mod + '.sql'), '\n'.join(frag) + '\n') + +# ── appcodes 种子数据(pbl_appcodes 幂等注入源)───────────────────────────── +w(os.path.join(MODS, 'pbl_appcodes', 'init', 'data.json'), + json.dumps({'appcodes': APPCODES}, ensure_ascii=False, indent=2) + '\n') + +print('generated/updated %d files, tables=%d, contracts=%d, appcodes=%d groups' + % (len(W), len(T), sum(len(v['contracts']) for v in MODULES.values()), len(APPCODES))) +for p in W: + print(' +', p) diff --git a/scripts/tests/offline_contract_smoke.py b/scripts/tests/offline_contract_smoke.py new file mode 100644 index 0000000..110531b --- /dev/null +++ b/scripts/tests/offline_contract_smoke.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbls 离线契约冒烟测试(无 DB / 无网络,用内存 FakeDB 驱动真实 api.py 代码路径)。 + +覆盖硬门禁与关键红线: +A 租户强制 + CRUD 白名单丢弃越权字段 +B 14 维校验 + 5 级质量状态阶梯(含 blocking 不放行) +C 确定性编译(draft 门禁拒 → 同输入两次指纹一致 → 改版本指纹变 → 幂等命中) +D 证据幂等(同 (source_event_id, evidence_type) 二次采集 deduped,不产生第二行) +E fail-closed 8 步裁决:Critic 写 / 禁用工具(pbl.publish) / 未注册 / 参数白名单 / 审批缺失 +F Rubric 权重合计≠100% 拒绝;=100% 通过且 total_score 派生 +G KDB 零写入 + 匿名聚合 k=5 抑制 + 个体维度拒绝 +H 运行时事件 seq/state_version 单调 + 乐观锁 + 8 人上限 + 时延预算实测 +I appcodes 幂等注入(二次执行 inserted=0) +J 入口挂载静态核验(ServerEnv 先于 load_rbac + 契约三处同步 + 12 loader 可导入) +K G6 十一条禁止实现零命中 +L 前端覆盖层 12 页面 + 渲染时延预算(可执行验证方式) +""" +import asyncio +import os +import re +import sys +import types + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, *(['..'] * 4))) + +for _m in sorted(os.listdir(os.path.join(ROOT, 'modules'))): + if _m.startswith('pbl_'): + sys.path.insert(0, os.path.join(ROOT, 'modules', _m)) +sys.path.insert(0, os.path.join(ROOT, 'apps', 'pbls', 'app')) + + +# ── FakeDB:支持 INSERT/UPDATE/DELETE/SELECT(等值+字面量+LIKE+>)/COUNT/MAX/GROUP BY ── +class FakeSor: + def __init__(self, db): + self.db = db + + async def sqlExe(self, sql, params=None): + p = dict(params or {}) + s = ' '.join(sql.split()) + self.db.log.append(s[:160]) + up = s.upper() + if up.startswith('INSERT'): + return self._insert(s, p) + if up.startswith('UPDATE'): + return self._update(s, p) + if up.startswith('DELETE'): + return self._delete(s, p) + return self._select(s, p, up) + + # ---- INSERT ---- + def _insert(self, s, p): + tbl = re.search(r'INSERT INTO `(\w+)`', s).group(1) + colpart = s[s.index('(') + 1:s.index(')')] + cols = re.findall(r'`(\w+)`', colpart) + vals = s[s.upper().index('VALUES') + 6:] + ph = re.findall(r'\$\{(\w+)\}\$', vals) + self.db.next_id += 1 + row = {} + for i, c in enumerate(cols): + row[c] = p.get(ph[i]) if i < len(ph) else None + row['id'] = self.db.next_id + self.db.rows.setdefault(tbl, []).append(row) + return [{'id': self.db.next_id}] + + # ---- UPDATE ---- + def _update(self, s, p): + tbl = re.search(r'UPDATE `(\w+)`', s).group(1) + where = s[s.upper().index(' WHERE ') + 7:] if ' WHERE ' in s.upper() else '' + setpart = s[s.upper().index(' SET ') + 5: s.upper().index(' WHERE ')] if ' WHERE ' in s.upper() \ + else s[s.upper().index(' SET ') + 5:] + sets = re.findall(r'`(\w+)`\s*=\s*\$\{(\w+)\}\$', setpart) + n = 0 + for row in self.db.rows.get(tbl, []): + if not self._match(row, where, p): + continue + for col, key in sets: + row[col] = p.get(key) + if '`use_count` = `use_count` + 1' in setpart: + row['use_count'] = int(row.get('use_count') or 0) + 1 + n += 1 + return [{'affected': n}] + + # ---- DELETE ---- + def _delete(self, s, p): + tbl = re.search(r'DELETE FROM `(\w+)`', s).group(1) + where = s[s.upper().index(' WHERE ') + 7:] if ' WHERE ' in s.upper() else '' + keep, n = [], 0 + for row in self.db.rows.get(tbl, []): + if self._match(row, where, p): + n += 1 + else: + keep.append(row) + self.db.rows[tbl] = keep + return [{'affected': n}] + + # ---- SELECT ---- + def _select(self, s, p, up): + if 'LAST_INSERT_ID' in up: + return [{'pk': self.db.next_id}] + m = re.search(r'FROM `(\w+)`', s) + tbl = m.group(1) if m else '?' + rows = self.db.rows.get(tbl, []) + where = '' + if ' WHERE ' in up: + w = s[s.upper().index(' WHERE ') + 7:] + where = re.split(r' ORDER BY | GROUP BY | LIMIT ', w)[0] + matched = [r for r in rows if self._match(r, where, p)] + gm = re.search(r'GROUP BY `(\w+)`', s) + if gm: + col = gm.group(1) + groups = {} + for r in matched: + groups.setdefault(r.get(col), []).append(r) + out = [] + for g, items in sorted(groups.items(), key=lambda x: str(x[0])): + scores = [float(i.get('total_score') or 0) for i in items] + out.append({'g': g, 'n': len(items), + 'avg_score': sum(scores) / len(scores) if scores else 0, + 'min_score': min(scores) if scores else 0, + 'max_score': max(scores) if scores else 0}) + return out + if 'COUNT(*)' in up: + return [{'c': len(matched)}] + m = re.search(r'COALESCE\((MAX|COUNT)\(`(\w+)`\),0\) AS (\w+)', s) + if m: + fn, col, alias = m.groups() + vals = [r.get(col) for r in matched if r.get(col) is not None] + if fn == 'COUNT': + return [{alias: len(matched)}] + try: + return [{alias: max(vals) if vals else 0}] + except TypeError: + return [{alias: 0}] + m = re.search(r'MAX\(`(\w+)`\) AS (\w+)', s) + if m: + col, alias = m.groups() + vals = [r.get(col) for r in matched if r.get(col) is not None] + try: + return [{alias: max(vals) if vals else 0}] + except TypeError: + return [{alias: 0}] + if 'DISTINCT' in up: + col = re.search(r'DISTINCT `(\w+)`', s).group(1) + seen = [] + for r in matched: + if r.get(col) is not None and r[col] not in seen: + seen.append(r[col]) + return [{col: v} for v in seen] + lim = re.search(r'LIMIT (\d+)', s) + if lim: + matched = matched[:int(lim.group(1))] + return [dict(r) for r in matched] + + def _match(self, row, where, p): + if not where: + return True + for cond in re.split(r' AND ', where): + cond = cond.strip().strip('()') + m = re.match(r'`(\w+)`\s*=\s*\$\{(\w+)\}\$$?', cond) + if m: + col, key = m.groups() + want = p.get(key) + if want in (None, ''): + continue + if str(row.get(col)) != str(want): + return False + continue + m = re.match(r'`(\w+)`\s*=\s*\'([^\']*)\'$', cond) + if m: + col, want = m.groups() + if str(row.get(col)) != want: + return False + continue + m = re.match(r'`(\w+)`\s*>\s*\$\{(\w+)\}\$$?', cond) + if m: + col, key = m.groups() + try: + if float(row.get(col) or 0) <= float(p.get(key) or 0): + return False + except (TypeError, ValueError): + return False + continue + m = re.match(r'`(\w+)` IN \(([^)]*)\)$', cond) + if m: + col, keys = m.group(1), re.findall(r'\$\{(\w+)\}', m.group(2)) + vals = [p.get(k) for k in keys if p.get(k) not in (None, '')] + if vals and str(row.get(col)) not in [str(v) for v in vals]: + return False + continue + if 'LIKE' in cond.upper(): + m = re.match(r'`(\w+)` LIKE \$\{(\w+)\}\$$?', cond) + if m: + col, key = m.groups() + pat = str(p.get(key) or '').replace('%', '') + if pat and pat not in str(row.get(col) or ''): + return False + continue + return True + + +class FakeCtx: + def __init__(self, db, name): + self.db, self.name = db, name + + async def __aenter__(self): + return FakeSor(self.db) + + async def __aexit__(self, *a): + return False + + +class FakeDBMod: + def __init__(self): + self.rows, self.log, self.next_id = {}, [], 0 + + def sqlorContext(self, name): + return FakeCtx(self, name) + + +fdb = FakeDBMod() +_ap = types.ModuleType('apppublic') +_ap.db = fdb +sys.modules['apppublic'] = _ap + +ah = types.ModuleType('ahserver') +se = types.ModuleType('ahserver.serverenv') + + +class ServerEnv: + _inst = None + + def __new__(cls): + if cls._inst is None: + cls._inst = object.__new__(cls) + return cls._inst + + def __init__(self): + self.pbl_default_tenant = 'T-DEMO' + + +se.ServerEnv = ServerEnv +ah.serverenv = se +sys.modules['ahserver'] = ah +sys.modules['ahserver.serverenv'] = se +_ge = types.ModuleType('ahserver.globalEnv') +_ge.password_encode = lambda s: s +sys.modules['ahserver.globalEnv'] = _ge +_wa = types.ModuleType('ahserver.webapp') +_wa.webapp = lambda init: None +sys.modules['ahserver.webapp'] = _wa + +RESULTS = [] + + +def check(name, cond, detail=''): + RESULTS.append((name, bool(cond), detail)) + print('%-4s %s %s' % ('PASS' if cond else 'FAIL', name, detail)) + + +async def main(): + from pbl_common import api as C + from pbl_blueprint import api as BP + from pbl_validation import api as VA + from pbl_compiler import api as CO + from pbl_agent_runtime import api as AR + from pbl_evidence import api as EV + from pbl_assessment import api as AS + from pbl_kdb_ext import api as KD + from pbl_runtime_ext import api as RT + from pbl_appcodes import api as AC + from pbl_scense_ext import api as SX + from pbl_domain_ext import api as DX + + # ── A 租户 + CRUD 白名单 ──────────────────────────────────────────── + r = await BP.pbl_blueprint_create(title='湿地调查', subject='science', grade='g5', + evil_column='x') + check('A1 蓝图创建返回 id', r.get('ok') and r.get('id'), str(r.get('id'))) + check('A2 越权字段被丢弃不入库', r.get('dropped_fields') == ['evil_column'], + str(r.get('dropped_fields'))) + check('A3 租户上下文注入', r.get('tenant_id') == 'T-DEMO', str(r.get('tenant_id'))) + rd = await BP.pbl_blueprint_read(id=r['id']) + check('A4 聚合读含 7 类子对象槽', set(rd['subobjects']) == { + 'learning_goal', 'role', 'mission', 'task', 'artifact_spec', 'evidence_spec', + 'reflection_spec'}, str(sorted(rd['subobjects']))) + + # ── B 14 维校验 + 5 级质量状态 ───────────────────────────────────── + for i, (code, title) in enumerate([('LG1', '理解生态链'), ('LG2', '数据建模'), + ('LG3', '公众表达')]): + await BP.pbl_blueprint_subobject_save(kind='learning_goal', blueprint_id=r['id'], + code=code, title=title, bloom_level='analyze', + measurable=1, order_no=i + 1) + await BP.pbl_blueprint_update(id=r['id'], content_json={ + 'schema': 'pbl.blueprint.v1', 'driving_question': '我们如何拯救社区湿地?', + 'scenario': '本地湿地富营养化,社区需要一份可执行方案', + 'learning_goals': [{'uid': 'LG1', 'code': 'LG1'}, {'uid': 'LG2', 'code': 'LG2'}, + {'uid': 'LG3', 'code': 'LG3'}], + 'rubric_criteria': [{'code': 'C1', 'learning_goal_id': 'LG1', 'weight': 40}, + {'code': 'C2', 'learning_goal_id': 'LG2', 'weight': 35}, + {'code': 'C3', 'learning_goal_id': 'LG3', 'weight': 25}]}) + await BP.pbl_blueprint_subobject_save(kind='mission', blueprint_id=r['id'], code='MS1', + title='拯救湿地', scenario_txt='富营养化现场', + order_no=1) + await BP.pbl_blueprint_subobject_save(kind='role', blueprint_id=r['id'], code='R1', + title='生态调研员', headcount=3, order_no=1) + await BP.pbl_blueprint_subobject_save(kind='role', blueprint_id=r['id'], code='R2', + title='数据分析师', headcount=3, order_no=2) + await BP.pbl_blueprint_subobject_save(kind='artifact_spec', blueprint_id=r['id'], code='A1', + title='治理方案', artifact_type='document', order_no=1) + await BP.pbl_blueprint_subobject_save(kind='evidence_spec', blueprint_id=r['id'], + code='E1', evidence_type='artifact', min_count=2, + rubric_criterion_uid='C1', order_no=1) + await BP.pbl_blueprint_subobject_save(kind='evidence_spec', blueprint_id=r['id'], + code='E2', evidence_type='reflection', min_count=1, + rubric_criterion_uid='C2', order_no=2) + await BP.pbl_blueprint_subobject_save(kind='reflection_spec', blueprint_id=r['id'], + code='RF1', trigger='mission.completed', + prompt_txt='你学到了什么?', min_words=50, + evidence_type='reflection', order_no=1) + v = await VA.pbl_validation_run(blueprint_id=r['id'], apply_state=1) + check('B1 校验 14 维全覆盖', v['dimension_count'] == 14, str(v['dimension_count'])) + check('B2 逐维可解释结论齐备', len(v['findings']) == 14 and all( + 'detail' in f for f in v['findings']), str(len(v['findings']))) + check('B3 质量状态在 5 级阶梯内', v['quality_state'] in + ['draft', 'needs_work', 'pbl_ready', 'playtest_ready', 'publish_ready'], + '%s score=%s' % (v['quality_state'], v['score'])) + check('B4 校验时延预算 ≤2000ms 实测', v['within_budget'], '%sms' % v['latency_ms']) + check('B5 blocking 未清零不得跳级', not (v['blocking'] > 0 and + v['quality_state'] == 'publish_ready'), + 'blocking=%s' % v['blocking']) + vfull = await VA.pbl_validation_run(blueprint_id=r['id']) + check('B6 重复校验产生新记录(历史不覆盖)', vfull['ok'], 'idempotency=n/a') + + # ── C 确定性编译 ──────────────────────────────────────────────────── + await CO.pbl_capability_register(capability_key='pbl.mission.show', category='mission', + is_enabled=1) + await CO.pbl_capability_register(capability_key='pbl.artifact.submit', category='artifact', + is_enabled=1) + try: + await CO.pbl_compiler_compile(blueprint_id=r['id']) + check('C0 draft/未达门禁编译被拒', False, '未抛错') + except C.PblError as e: + check('C0 质量门禁拒编译', e.code == 'PBL_COMPILE_GATE_FAILED', e.code) + await BP.pbl_blueprint_version_create(blueprint_id=r['id']) + c1 = await CO.pbl_compiler_compile(blueprint_id=r['id'], min_quality_state='draft') + c2 = await CO.pbl_compiler_compile(blueprint_id=r['id'], min_quality_state='draft') + check('C1 编译产出 SHA-256 指纹', len(str(c1.get('content_fingerprint'))) == 64, + str(c1.get('content_fingerprint'))[:16]) + check('C2 同输入+同编译版本 → 同指纹(29.6)', + c1['content_fingerprint'] == c2['content_fingerprint'], + '%s ==' % c1['content_fingerprint'][:12]) + check('C2b 同指纹命中幂等(不重复建行)', c2.get('idempotent') is True, + str(c2.get('note'))[:20]) + pv = await CO.pbl_compiler_preview(blueprint_id=r['id']) + check('C3 干跑不落库且指纹一致', pv['content_fingerprint'] == c1['content_fingerprint'] + and pv['preview'], 'entities=%s' % len(pv['definition']['entities'])) + check('C3b 事件映射为 script_type=1 规则 JSON(不建规则引擎)', + all(b['script_type'] == 1 for b in pv['definition']['script_bindings']) + and len(pv['definition']['script_bindings']) == len(pv['definition']['events']), + 'bindings=%s' % len(pv['definition']['script_bindings'])) + await BP.pbl_blueprint_update(id=r['id'], content_json={'schema': 'pbl.blueprint.v1', + 'missions': [{'obj_uid': 'M9'}]}) + await BP.pbl_blueprint_version_create(blueprint_id=r['id']) + c3 = await CO.pbl_compiler_compile(blueprint_id=r['id'], blueprint_version_no=2, + min_quality_state='draft', force_new=1) + check('C4 改输入 → 指纹变(非缓存假阳性)', + c3['content_fingerprint'] != c1['content_fingerprint'], 'v2 fp 不同') + cmp_ = await CO.pbl_compiler_compare(a=c1['id'], b=c3['id']) + check('C5 两版定义结构化 diff 可用', cmp_['ok'] and cmp_['identical'] is False, + 'added=%s removed=%s' % (len(cmp_['diff']['added']), len(cmp_['diff']['removed']))) + check('C6 编译时延预算 ≤5000ms 实测', c3['latency_ms'] <= c3['budget_ms'], + '%sms' % c3['latency_ms']) + mat = await DX.pbl_domain_materialize_game_definition(id=c3['id']) + check('C7 物化走基座契约/降级如实回报', mat['ok'] and 'degraded' in mat and + mat.get('base_tables_altered') is False, 'degraded=%s' % mat.get('degraded')) + + # ── E fail-closed 8 步裁决 ────────────────────────────────────────── + await AR.pbl_tool_registry_save(tool_key='pbl.get', title='读蓝图', enabled=1, + agent_scope='both') + await AR.pbl_tool_registry_save(tool_key='pbl.update', title='改蓝图', enabled=1, + agent_scope='designer', + params_schema_json={'properties': {'blueprint_id': {}, + 'content_json': {}}}) + await AR.pbl_tool_registry_save(tool_key='pbl.publish', title='发布', enabled=0, + agent_scope='none', disabled_reason='G6_no_publish_tool') + from pbl_common.api import sql_exec as _x + for code, mode in (('designer', 'write'), ('critic', 'read')): + await _x('INSERT INTO `pbl_agent_def` (`tenant_id`,`code`,`agent_type`,`name`,' + '`model_route`,`permission_mode`,`is_enabled`,`version_no`,`created_at`)' + ' VALUES (${t}$,${c}$,${a}$,${n}$,${r}$,${m}$,${en}$,${vn}$,${ts}$)', + {'t': 'T-DEMO', 'c': code, 'a': code, 'n': code, 'r': 'pipeline-llm', + 'm': mode, 'en': 1, 'vn': 1, 'ts': '2026-09-15 10:00:00'}, 'pbl') + d1 = await AR.pbl_tool_adjudicate(agent_code='critic', tool_key='pbl.update', + params_json={'blueprint_id': 1}) + check('E1 Critic 写工具 DENIED(零写权限)', + d1['allowed'] is False and d1['error_code'] == 'AD04_PERMISSION_MODE_DENIED', + d1['error_code']) + d1b = await AR.pbl_tool_adjudicate(agent_code='critic', tool_key='pbl.get', params_json={}) + check('E1b Critic 读工具放行', d1b['allowed'] is True, str(d1b['steps'][-1])) + d2 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.publish', + params_json={}) + check('E2 pbl.publish 恒拒(G6 无绕过)', + d2['allowed'] is False and d2['error_code'] == 'AD03_TOOL_DISABLED', d2['error_code']) + d3 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='evil.tool', params_json={}) + check('E3 未注册工具 DENIED', d3['error_code'] == 'AD02_TOOL_UNREGISTERED', d3['error_code']) + d4 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.update', + params_json={'evil': 1}) + check('E4 参数不在服务端白名单 DENIED', d4['error_code'] == 'AD05_PARAM_NOT_WHITELISTED', + d4['error_code']) + d5 = await AR.pbl_tool_adjudicate(agent_code='designer', tool_key='pbl.update', + params_json={'blueprint_id': 1}) + check('E5 需人工审批且无 approved 单 → DENIED', + d5['error_code'] == 'AD06_APPROVAL_REQUIRED', d5['error_code']) + ap = await AR.pbl_approval_create(approval_type='modify_learning_goal', + target_type='pbl_blueprint', target_id=r['id']) + check('E5b 审批单创建为 pending', ap.get('ok') and ap.get('approval_uid'), + str(ap.get('approval_uid'))[:12]) + dec = await AR.pbl_approval_decide(id=ap['id'], status='approved', comment_txt='同意') + check('E5c 人工决策后放行(第6步通过)', dec.get('ok') is True, dec.get('status')) + try: + await AR.pbl_approval_create(approval_type='auto_publish', target_id=1) + check('E6 非法审批类型被拒', False, '未抛错') + except C.PblError as e: + check('E6 非法审批类型被拒', e.code == 'PBL_APPROVAL_TYPE_UNKNOWN', e.code) + try: + await AR.pbl_tool_registry_save(tool_key='sim.configure', enabled=1) + check('E7 G6 禁用工具不得启用', False, '未抛错') + except C.PblError as e: + check('E7 G6 禁用工具不得启用', e.code == 'PBL_TOOL_FORBIDDEN', e.code) + tl = await AR.pbl_tool_registry_list() + check('E8 13 启用 / 9 禁用清单齐备', + len(tl['enabled_keys']) == 13 and len(tl['disabled_keys']) == 9 + and 'pbl.publish' in tl['disabled_keys'], str(tl['counts'])) + cr = await AR.pbl_agent_critic_run(blueprint_id=r['id']) + check('E9 Critic 输出四要素且 writable=False', + cr['writable'] is False and all(k in cr for k in + ('recommendation', 'reason', 'evidence', + 'confidence')), 'conf=%s' % cr['confidence']) + dr = await AR.pbl_agent_designer_run(input_txt='给我做一个湿地 PBL', subject='science', + grade='g5', template_code='TPL-NOT-EXIST') + check('E10 LLM 不可达 → 兜底路径闭环不中断', + dr['ok'] and dr['generation_source'] == 'template_fallback', + 'err=%s %sms' % (dr['llm_error'], dr['latency_ms'])) + tr = await AR.pbl_agent_trace_list() + check('E11 轨迹留痕(7 要素)可读', tr['ok'] and tr['total'] >= 2, 'traces=%s' % tr['total']) + + # ── D 证据幂等 ────────────────────────────────────────────────────── + a1 = await EV.pbl_artifact_create(title='湿地报告', session_id=1, blueprint_id=r['id'], + content_json={'a': 1}) + e1 = await EV.pbl_evidence_collect(evidence_type='artifact', source_event_id='EV-1', + session_id=1, learner_id='U1', artifact_id=a1['id']) + e2 = await EV.pbl_evidence_collect(evidence_type='artifact', source_event_id='EV-1', + session_id=1, learner_id='U1', artifact_id=a1['id']) + check('D1 首次采集落库', e1.get('deduped') is False, str(e1.get('id'))) + check('D2 重复采集幂等(唯一索引)', e2.get('deduped') is True and + e2.get('id') == e1.get('id'), '%s==%s' % (e1.get('id'), e2.get('id'))) + check('D2b 幂等键为 (tenant,source_event_id,evidence_type)', + 'uk_ev_dedup' in e1.get('unique_index', ''), e1.get('unique_index')) + try: + await EV.pbl_evidence_collect(evidence_type='artifact') + check('D3 缺 source_event_id 拒绝', False, '未抛错') + except C.PblError as e: + check('D3 缺 source_event_id 拒绝', e.code == 'PBL_PARAM_MISSING', e.code) + try: + await EV.pbl_evidence_collect(evidence_type='ghost', source_event_id='X') + check('D3b 非法证据类型拒绝', False, '未抛错') + except C.PblError as e: + check('D3b 非法证据类型拒绝', e.code == 'PBL_EVIDENCE_TYPE_INVALID', e.code) + au = await EV.pbl_artifact_update(id=a1['id'], content_json={'a': 2}) + check('D4 产出物更新版本递增', au.get('ok'), 'v=%s' % au.get('version_no')) + el = await EV.pbl_evidence_list(session_id=1, learner_id='U1') + check('D5 证据按类型聚合可读', el['ok'] and 'by_type' in el, str(el['by_type'])) + + # ── F Rubric ──────────────────────────────────────────────────────── + try: + await AS.pbl_rubric_save(title='湿地评分', blueprint_id=r['id'], criteria_json=[ + {'code': 'C1', 'weight': 60, 'learning_goal_id': 'LG1', 'max_points': 5}]) + check('F1 权重≠100% 拒绝', False, '未抛错') + except C.PblError as e: + check('F1 权重≠100% 拒绝', e.code == 'PBL_RUBRIC_WEIGHT_INVALID', e.code) + try: + await AS.pbl_rubric_save(title='x', blueprint_id=r['id'], criteria_json=[ + {'code': 'C1', 'weight': 100}]) + check('F1b 准则缺 learning_goal_id 拒绝', False, '未抛错') + except C.PblError as e: + check('F1b 准则缺 learning_goal_id 拒绝', + e.code == 'PBL_RUBRIC_ALIGNMENT_MISSING', e.code) + ru = await AS.pbl_rubric_save(title='湿地评分', blueprint_id=r['id'], criteria_json=[ + {'code': 'C1', 'weight': 60, 'learning_goal_id': 'LG1', 'max_points': 5}, + {'code': 'C2', 'weight': 40, 'learning_goal_id': 'LG2', 'max_points': 5}]) + check('F2 权重=100% 通过', ru.get('ok') and ru.get('weight_total_ok'), str(ru.get('id'))) + sc = await AS.pbl_assessment_score(rubric_id=ru['id'], learner_id='U1', + scores_json={'C1': 4, 'C2': 3}) + check('F3 加权总分派生(4/5×60 + 3/5×40 = 72)', abs(sc['total_score'] - 72.0) < 0.01, + '%s band=%s' % (sc['total_score'], sc['band'])) + check('F3b 客户端不可直传 total_score', 'total_score' not in + [k for k in sc.keys() if k == 'total_score'] or sc.get('derived'), + str(sc.get('derived'))[:24]) + rep = await AS.pbl_assessment_report(learner_id='U1') + check('F4 教师报告可读', rep.get('ok') and 'learners' in rep, str(rep.get('record_count'))) + + # ── G KDB 只读桩 ──────────────────────────────────────────────────── + ks = await KD.pbl_kdb_search(keyword='湿地') + check('G1 KDB 检索为只读桩', ks.get('writable') is False and 'stub' in ks, ks.get('stub')) + check('G2 pbl_kdb_item 零 INSERT', + not any('INSERT INTO `pbl_kdb_item`' in x for x in fdb.log), 'log clean') + ag = await KD.pbl_research_aggregate(group_by='band') + check('G3 匿名聚合只读 + k_min=5', ag.get('anonymous') and ag.get('written') is False + and ag.get('k_min') == 5, 'groups=%s suppressed=%s' % (len(ag['groups']), + ag['suppressed_groups'])) + try: + await KD.pbl_research_aggregate(group_by='learner_id') + check('G4 个体维度聚合被拒', False, '未抛错') + except C.PblError as e: + check('G4 个体维度聚合被拒', e.code == 'PBL_AGG_DIM_INVALID', e.code) + check('G5 无 kdb.write/research.write/sim.* 实现入口', + not any(hasattr(KD, n) for n in ('kdb_write', 'pbl_kdb_write', 'pbl_research_write', + 'pbl_sim_configure', 'pbl_kdb_add_candidate')), + '零实现入口') + + # ── H 运行时(服务端权威)─────────────────────────────────────────── + m1 = await RT.pbl_session_member_add(session_id=1, user_id='U1') + m2 = await RT.pbl_session_member_add(session_id=1, user_id='U1') + check('H1 成员重复加入幂等', m2.get('deduped') is True, str(m2.get('members'))) + st = await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1', + state_json={'completed': True}, + event_type='mission.completed', state_version=999) + check('H2 客户端 state_version 被忽略', st.get('client_version_ignored') is True + and st['state_version'] == 1, 'v=%s' % st['state_version']) + st2 = await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1', + state_json={'x': 1}) + check('H3 state_version 单调递增', st2['state_version'] == 2, str(st2['state_version'])) + check('H4 单事务 + 裁决/端到端时延实测在预算内', st2['within_budget'] is True + and st2['transaction'] == 'single', json.dumps(st2['measured'])) + check('H4b 广播时延预算 ≤300ms 实测', st2['broadcast']['within_budget'], + '%sms ch=%s' % (st2['broadcast']['latency_ms'], st2['broadcast']['channel'])) + pl = await RT.pbl_runtime_event_poll(session_id=1, since_seq=0) + check('H5 3s 轮询兜底可拉增量事件', pl['count'] >= 1 and pl['last_seq'] >= 1, + 'events=%s interval=%ss' % (pl['count'], pl['poll_interval_seconds'])) + try: + await RT.pbl_entity_state_apply(session_id=1, entity_id='mission:M1', + state_json={'y': 2}, if_state_version=1) + check('H6 乐观锁冲突被拒', False, '未抛错') + except C.PblError as e: + check('H6 乐观锁冲突被拒', e.code == 'PBL_STATE_CONFLICT', e.code) + for i in range(2, 9): + await RT.pbl_session_member_add(session_id=1, user_id='U%d' % i) + try: + await RT.pbl_session_member_add(session_id=1, user_id='U99') + check('H7 超 8 人上限被拒', False, '未抛错') + except C.PblError as e: + check('H7 超 8 人上限被拒', e.code == 'PBL_SESSION_FULL', e.code) + check('H8 事件表无 UPDATE/DELETE 出口(append-only)', + not any(x.startswith('UPDATE `pbl_runtime_event`') or + x.startswith('DELETE FROM `pbl_runtime_event`') for x in fdb.log), 'clean') + + # ── I appcodes 幂等 ───────────────────────────────────────────────── + i1 = await AC.pbl_appcodes_inject() + i2 = await AC.pbl_appcodes_inject() + check('I1 6+2 组枚举注入', i1['groups'] == 8 and i1['inserted'] > 0, + 'inserted=%s' % i1['inserted']) + check('I2 二次执行零新增(幂等)', i2['inserted'] == 0 and i2['skipped'] == i1['inserted'], + 'skipped=%s' % i2['skipped']) + cl = await AC.pbl_appcodes_list() + check('I3 枚举组可读回', len(cl['groups']) >= 1, str(sorted(cl['groups'])[:3])) + + # ── J 入口挂载静态核验 ────────────────────────────────────────────── + src = open(os.path.join(ROOT, 'apps', 'pbls', 'app', 'pbls.py'), encoding='utf-8').read() + i_env, i_rbac = src.index('env = ServerEnv()'), src.index('load_rbac()') + check('J1 ServerEnv() 先于 load_rbac()', 0 < i_env < i_rbac, + 'env@%d < rbac@%d' % (i_env, i_rbac)) + check('J2 基础模块显式 loader 调用', + all(x in src for x in ('load_appbase()', 'load_rbac()', 'load_pybricks()')), 'ok') + check('J2b 无 importlib 只 import 即标 loaded 的反模式', + 'importlib' not in src or 'getattr(pkg' in src, 'ok') + bad = [] + mods = ('pbl_common', 'pbl_appcodes', 'pbl_blueprint', 'pbl_validation', 'pbl_compiler', + 'pbl_agent_runtime', 'pbl_evidence', 'pbl_assessment', 'pbl_kdb_ext', + 'pbl_domain_ext', 'pbl_scense_ext', 'pbl_runtime_ext') + for mod in mods: + pkg = os.path.join(ROOT, 'modules', mod, mod) + init_src = open(os.path.join(pkg, 'init.py'), encoding='utf-8').read() + exp_src = open(os.path.join(pkg, '__init__.py'), encoding='utf-8').read() + api_src = open(os.path.join(pkg, 'api.py'), encoding='utf-8').read() + for n in re.findall(r'async def (pbl_\w+)', api_src): + if ('env.%s = %s' % (n, n)) not in init_src or n not in exp_src: + bad.append('%s:%s' % (mod, n)) + check('J3 契约三处同步(定义+导出+env注册)', not bad, '缺=%s' % (bad[:5] or '无')) + check('J4 12 模块 load_* 均可导入', + all(hasattr(__import__('importlib').import_module('%s.init' % m), 'load_%s' % m) + for m in mods), 'ok') + check('J5 模块嵌套包结构 modules/{m}/{m}/', + all(os.path.isfile(os.path.join(ROOT, 'modules', m, m, 'api.py')) for m in mods), 'ok') + check('J6 模块无 app.py / 无独立端口(模块非部署单元)', + not any(os.path.exists(os.path.join(ROOT, 'modules', m, 'app.py')) or + os.path.exists(os.path.join(ROOT, 'modules', m, 'app', '%s.py' % m)) + for m in mods), 'ok') + + # ── K G6 十一条禁止实现 ───────────────────────────────────────────── + g6 = ['pbl_publish', 'marketplace', 'showplace_publish', 'sim_configure', + 'kdb_add_candidate', 'kdb_propose_pattern', 'research_write', 'crdt', + 'frame_sync', 'unity', 'sso_billing', 'autonomous_agent'] + hits = {} + for term in g6: + n = 0 + for mod in mods: + pkg = os.path.join(ROOT, 'modules', mod, mod) + for fn in os.listdir(pkg): + if not fn.endswith('.py'): + continue + t = open(os.path.join(pkg, fn), encoding='utf-8').read() + n += len(re.findall(r'(?:async def|def)\s+\w*%s\w*\s*\(' % term, t, re.I)) + hits[term] = n + check('K1 G6 十一条禁止实现零命中(无函数定义)', + all(v == 0 for v in hits.values()), str({k: v for k, v in hits.items() if v}) or 'all 0') + + # ── L 前端覆盖层 ──────────────────────────────────────────────────── + pr = await SX.pbl_page_registry_list() + check('L1 12 页面注册齐备', pr['page_count'] == 12, str(pr['page_count'])) + world = [p for p in pr['data'] if p['widget_type'] == 'world_overlay' or + p['page_key'] in ('pbl_play_preview', 'pbl_mission_board', 'pbl_role_panel', + 'pbl_artifact_studio', 'pbl_evidence_wall')] + check('L2 世界内页面预算 500ms', all(int(p['budget_ms']) == 500 for p in world), + '%s pages' % len(world)) + check('L2b 首屏预算 1500ms', pr['budgets']['first_screen_ms'] == 1500, + json.dumps(pr['budgets'])) + bs = await SX.pbl_overlay_bootstrap(session_id=1) + check('L3 覆盖层一次请求拿齐上下文 + 时延实测', + bs['ok'] and len(bs['pages']) == 12 and bs['within_budget'], + '%sms budget=%sms' % (bs['latency_ms'], bs['budget_ms'])) + fb = await SX.pbl_playtest_feedback_save(session_id=1, blueprint_id=r['id'], + feedback_type='reflection', + content_txt='我学会了用数据说服社区', rating=4) + check('L4 反思写入并联动证据链', fb.get('ok'), str(fb.get('id'))) + try: + await SX.pbl_playtest_feedback_save(feedback_type='ghost') + check('L4b 非法反馈类型被拒', False, '未抛错') + except C.PblError as e: + check('L4b 非法反馈类型被拒', e.code == 'PBL_FEEDBACK_TYPE_INVALID', e.code) + ctx = await SX.pbl_session_context_get(session_id=1) + check('L5 会话上下文只读(authority=server)', ctx['authority'] == 'server' + and ctx['max_members'] == 8, 'members=%s' % ctx['count']) + + fails = [x for x in RESULTS if not x[1]] + print('\n==== 结果:%d 项,通过 %d,失败 %d ====' % (len(RESULTS), len(RESULTS) - len(fails), + len(fails))) + for name, _ok, detail in fails: + print(' FAIL', name, detail) + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(asyncio.get_event_loop().run_until_complete(main())) diff --git a/wwwroot/pbl_overlay/pbl_overlay.js b/wwwroot/pbl_overlay/pbl_overlay.js new file mode 100644 index 0000000..e5714f5 --- /dev/null +++ b/wwwroot/pbl_overlay/pbl_overlay.js @@ -0,0 +1,225 @@ +/* + * pbl_overlay.js — PBL 前端覆盖层(M9) + * 挂载方式:在 scense 渲染容器之上叠加 DOM 覆盖层 + 事件处理器, + * 不修改 scense/scense_game 渲染主循环(render loop untouched)。 + * 性能承诺:广播渲染 <= 500ms(poll/subscribe 增量应用);首屏 <= 1.5s(骨架先行 + 懒加载数据)。 + * 页面:12 页面(page_code 路由);组件:11 个;错误降级态:9 类。 + */ +(function (global) { + 'use strict'; + + var API = '/api/pbls'; // 应用唯一入口 :9300 + var POLL_MS = 3000; // 3s 轮询兜底(与后端 POLL_FALLBACK_MS 一致) + var BROADCAST_BUDGET_MS = 500; // 广播渲染预算 + var FIRST_SCREEN_BUDGET_MS = 1500; // 首屏预算 + + // 12 页面(ui-design.md) + var PAGES = ['blueprint_list', 'blueprint_editor', 'goal_editor', 'scene_editor', + 'step_editor', 'rule_editor', 'validation_report', 'compile_artifacts', + 'agent_chat', 'approval_center', 'play_launcher', 'assessment_report']; + + // 11 组件(覆盖层内,不改渲染层) + var COMPONENTS = ['TopBar', 'NavRail', 'GoalCard', 'SceneCard', 'StepList', 'RubricEditor', + 'ValidationBadge', 'ArtifactViewer', 'ChatPanel', 'ApprovalQueue', 'HudOverlay']; + + // 9 类错误降级态 + var ERROR_STATES = { + NET_FAIL: ['网络中断', '显示缓存数据 + 重试按钮'], + AUTH_FAIL: ['登录失效', '跳转登录,保留草稿于 localStorage'], + LOAD_FAIL: ['数据加载失败', '骨架占位 + 局部重试'], + VALIDATE_BLOCK: ['质量门禁未过', '阻断编译按钮,跳转校验报告'], + APPROVAL_PENDING: ['待人工审批', '按钮置灰 + 审批单号提示'], + LLM_FALLBACK: ['LLM 不可用', '离线模板兜底提示(US-13)'], + RATE_LIMIT: ['请求过于频繁', '退避倒计时后自动重试'], + SESSION_ENDED: ['会话已结束', '只读回放模式'], + RENDER_DEGRADED: ['渲染降级', '覆盖层保留,2D 占位图'] + }; + + function PblOverlay(opts) { + this.root = opts.root; // 覆盖层容器(scense 容器之上) + this.playSessionId = opts.playSessionId || null; + this.participantId = opts.participantId || null; + this.tenantId = opts.tenantId; + this.afterSeq = 0; + this.timer = null; + this.state = {}; + this.mount(); + } + + PblOverlay.prototype.mount = function () { + var t0 = performance.now(); + this.root.className = 'pbl-overlay'; + this.root.innerHTML = + '
' + + '
' + + '
' + + '
'; + this.bindEvents(); + this.loadFirstScreen().then(function () { + var cost = performance.now() - t0; + if (cost > FIRST_SCREEN_BUDGET_MS) { + global.console.warn('[pbl_overlay] first screen %.0fms > %.0fms budget', + cost, FIRST_SCREEN_BUDGET_MS); + } + }); + }; + + /* 事件处理器:只监听/派发,不介入渲染主循环 */ + PblOverlay.prototype.bindEvents = function () { + var self = this; + this.root.addEventListener('click', function (e) { + var act = e.target.getAttribute('data-act'); + if (!act) return; + var handlers = { + 'send-command': function (el) { self.sendCommand(el); }, + 'retry': function () { self.pollOnce(); }, + 'goto-approval': function () { self.showPage('approval_center'); }, + 'goto-validation': function () { self.showPage('validation_report'); } + }; + if (handlers[act]) handlers[act](e.target); + }); + // scense 侧事件桥(scense 通过 CustomEvent 通知,覆盖层被动响应) + if (global.addEventListener) { + global.addEventListener('scense:entity-clicked', function (e) { + self.onEntityClicked(e.detail); + }); + global.addEventListener('scense:ready', function () { self.degrade('RENDER_DEGRADED', false); }); + } + }; + + PblOverlay.prototype.loadFirstScreen = function () { + var self = this; + return this.api('play.list', { blueprint_id: self.blueprintId }).then(function (r) { + self.state.plays = (r.data && r.data.items) || []; + self.renderHud(); + }).catch(function () { self.degrade('LOAD_FAIL', true); }); + }; + + /* 服务端权威命令:client_seq 幂等;state_version 由服务端维护,客户端不传不校验 */ + PblOverlay.prototype.sendCommand = function (el) { + var self = this; + this._seq = (this._seq || 0) + 1; + var body = { + play_session_id: this.playSessionId, + actor_id: this.participantId, + client_seq: 'c-' + (this.participantId || 'anon') + '-' + this._seq, + entity_id: el.getAttribute('data-entity'), + capability: el.getAttribute('data-cap'), + command: el.getAttribute('data-cmd'), + state_delta: JSON.parse(el.getAttribute('data-delta') || '{}') + }; + return this.api('runtime.command', body).then(function (r) { + var d = r.data || {}; + if (d.ack_result === 'DENIED') { + self.toast('操作被拒绝: ' + (d.reason || ''), true); + } else if (d.ack_result === 'ACCEPTED') { + self.afterSeq = Math.max(self.afterSeq, d.event_seq || 0); + } + return d; + }).catch(function (e) { + self.degrade(String(e.code || 'NET_FAIL'), true); + }); + }; + + PblOverlay.prototype.onEntityClicked = function (detail) { + if (!detail) return; + this.api('runtime.state_read', { + play_session_id: this.playSessionId, entity_id: detail.entityId + }).then(function (r) { + global.dispatchEvent(new CustomEvent('pbl:entity-state', { detail: r.data })); + }).catch(function () { }); + }; + + /* 增量拉取并渲染:预算 500ms,超时降级为下一次轮询 */ + PblOverlay.prototype.pollOnce = function () { + if (!this.playSessionId) return; + var self = this, t0 = performance.now(); + this.api('runtime.poll', { play_session_id: this.playSessionId, after_seq: this.afterSeq }) + .then(function (r) { + var d = r.data || {}; + (d.events || []).forEach(function (ev) { + self.afterSeq = Math.max(self.afterSeq, ev.event_seq); + if (performance.now() - t0 > BROADCAST_BUDGET_MS) { return; } + global.dispatchEvent(new CustomEvent('pbl:broadcast', { detail: ev })); + }); + self.state.states = d.states || self.state.states; + self.renderHud(); + }) + .catch(function () { self.degrade('NET_FAIL', true); }); + }; + + PblOverlay.prototype.startPolling = function () { + var self = this; + this.stopPolling(); + this.timer = setInterval(function () { self.pollOnce(); }, POLL_MS); + }; + PblOverlay.prototype.stopPolling = function () { + if (this.timer) { clearInterval(this.timer); this.timer = null; } + }; + + PblOverlay.prototype.renderHud = function () { + var hud = this.root.querySelector('.pbl-hud'); + if (!hud) return; + var goals = (this.state.goalProgress || []); + hud.innerHTML = goals.map(function (g) { + return '
' + escapeHtml(g.goal_code) + ' : ' + g.percent + '%
'; + }).join(''); + }; + + PblOverlay.prototype.showPage = function (code) { + if (PAGES.indexOf(code) < 0) { return; } + var main = this.root.querySelector('.pbl-main'); + main.setAttribute('data-page', code); + this.api('overlay.save', { + play_session_id: this.playSessionId, participant_id: this.participantId, + page_code: code, overlay: { visited_at: Date.now() } + }).catch(function () { }); + }; + + PblOverlay.prototype.degrade = function (key, on) { + var info = ERROR_STATES[key]; + if (!info) return; + var box = this.root.querySelector('.pbl-error') || document.createElement('div'); + if (on) { + box.className = 'pbl-error'; + box.innerHTML = '' + info[0] + '' + info[1] + '' + + ''; + if (!box.parentNode) this.root.appendChild(box); + } else if (box.parentNode) { + box.parentNode.removeChild(box); + } + }; + + PblOverlay.prototype.toast = function (msg, isErr) { + var t = document.createElement('div'); + t.className = 'pbl-toast' + (isErr ? ' pbl-toast-err' : ''); + t.textContent = msg; + this.root.appendChild(t); + setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 3000); + }; + + PblOverlay.prototype.api = function (contract, body) { + var self = this; + return fetch(API + '/' + contract, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Tenant-Id': this.tenantId || '' }, + credentials: 'include', + body: JSON.stringify(body || {}) + }).then(function (r) { + if (r.status === 401) { self.degrade('AUTH_FAIL', true); throw { code: 'AUTH_FAIL' }; } + if (r.status === 429) { self.degrade('RATE_LIMIT', true); throw { code: 'RATE_LIMIT' }; } + return r.json(); + }); + }; + + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + global.PblOverlay = PblOverlay; + global.PBL_PAGES = PAGES; + global.PBL_COMPONENTS = COMPONENTS; + global.PBL_ERROR_STATES = ERROR_STATES; +})(window);