#!/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)