deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 15:54:44 +08:00
parent 970a6256fb
commit 650aac6fe0
3 changed files with 374 additions and 451 deletions

View File

@ -1,152 +1,144 @@
{ {
"_meta": { "_meta": {
"module": "pbl_appcodes", "module": "pbl_appcodes",
"app": "pbls",
"dialect": "mariadb", "dialect": "mariadb",
"groups": 8, "groups": 8,
"items": 74, "items": 74,
"target_tables": ["appcodes", "appcodes_kv"],
"idempotent": true, "idempotent": true,
"zero_table": true, "zero_table": true,
"spec_ref": "projects/pbls/docs/01-design/appcodes.md", "source": "projects/pbls/docs/01-design/appcodes.md",
"note": "6 组业务枚举 + 2 组运行时枚举 = 8 组 74 项;注入幂等(重复执行零差异);本模块零自有表。" "note": "6 组业务字典 + 2 组 Agent 工具启停字典13 启用 / 9 禁用,对齐 agent-tool-contract.md。编码全局唯一每组 sort 从 1 连续递增。注入走 appcodes 平台字典,本模块零建表。"
}, },
"groups": [ "dicts": [
{ {
"group_code": "pbl_tenant_status", "group": "pbl_blueprint_type",
"group_name": "PBL 租户状态", "group_name": "PBL蓝图类型",
"module": "pbl_appcodes", "module": "pbl_blueprint",
"items_count": 3,
"items": [ "items": [
{"code": "active", "name": "启用", "sort": 1, "remark": "正常可用"}, {"code": "BP_COURSE", "name": "课程蓝图", "sort": 1, "remark": "顶层聚合根"},
{"code": "suspended", "name": "停用", "sort": 2, "remark": "暂停服务,数据保留"}, {"code": "BP_MODULE", "name": "模块蓝图", "sort": 2, "remark": "课程下模块"},
{"code": "closed", "name": "注销", "sort": 3, "remark": "终止合作,只读归档"} {"code": "BP_UNIT", "name": "单元蓝图", "sort": 3, "remark": "模块下单元"},
{"code": "BP_LESSON", "name": "课时蓝图", "sort": 4, "remark": "单元下课时"},
{"code": "BP_ACTIVITY", "name": "活动蓝图", "sort": 5, "remark": "课时内活动"},
{"code": "BP_TEMPLATE", "name": "模板蓝图", "sort": 6, "remark": "离线兜底模板,可实例化"}
] ]
}, },
{ {
"group_code": "pbl_role", "group": "pbl_quality_state",
"group_name": "PBL 租户内角色", "group_name": "PBL质量状态(5级)",
"module": "pbl_appcodes", "module": "pbl_validation",
"items_count": 5,
"items": [ "items": [
{"code": "admin", "name": "项目管理员", "sort": 1, "remark": "租户内全权,复用 rbac 不改其基表"}, {"code": "QS_L1_DRAFT", "name": "L1 草稿", "sort": 1, "remark": "未校验"},
{"code": "teacher", "name": "教师", "sort": 2, "remark": "蓝图设计/校验/编译/发布"}, {"code": "QS_L2_STRUCTURED", "name": "L2 结构完整", "sort": 2, "remark": "结构维度通过"},
{"code": "assistant", "name": "助教", "sort": 3, "remark": "协助运行与证据采集"}, {"code": "QS_L3_VALIDATED", "name": "L3 校验通过", "sort": 3, "remark": "14 维全通过"},
{"code": "student", "name": "学生", "sort": 4, "remark": "世界内运行与产出"}, {"code": "QS_L4_COMPILED", "name": "L4 已编译", "sort": 4, "remark": "产出 Game Definition"},
{"code": "observer", "name": "观察者", "sort": 5, "remark": "只读看板与分析"} {"code": "QS_L5_READY", "name": "L5 可发布就绪", "sort": 5, "remark": "发布动作属 G6 禁用,仅置状态"}
] ]
}, },
{ {
"group_code": "pbl_bp_status", "group": "pbl_validation_dim",
"group_name": "PBL 蓝图状态", "group_name": "PBL校验维度(14维)",
"module": "pbl_appcodes", "module": "pbl_validation",
"items_count": 6,
"items": [ "items": [
{"code": "draft", "name": "草稿", "sort": 1, "remark": "可编辑"}, {"code": "VD_01_STRUCTURE", "name": "结构完整性", "sort": 1},
{"code": "validating", "name": "校验中", "sort": 2, "remark": "14 维校验执行中"}, {"code": "VD_02_OBJECTIVE", "name": "学习目标可测性", "sort": 2},
{"code": "validated", "name": "已校验", "sort": 3, "remark": "无 blocker可编译"}, {"code": "VD_03_ALIGNMENT", "name": "目标-活动-评价一致性", "sort": 3},
{"code": "compiling", "name": "编译中", "sort": 4, "remark": "确定性编译进行中"}, {"code": "VD_04_SUBOBJECT", "name": "7类子对象契约合规", "sort": 4},
{"code": "published", "name": "已发布", "sort": 5, "remark": "产出 Game Definition 并发布到班级"}, {"code": "VD_05_NARRATIVE", "name": "叙事世界观自洽", "sort": 5},
{"code": "archived", "name": "已归档", "sort": 6, "remark": "只读留存"} {"code": "VD_06_ROLE", "name": "角色与分工完备", "sort": 6},
{"code": "VD_07_TASK", "name": "任务链可达性", "sort": 7},
{"code": "VD_08_RULE", "name": "规则可执行性", "sort": 8},
{"code": "VD_09_EVIDENCE", "name": "证据采集点覆盖", "sort": 9},
{"code": "VD_10_RUBRIC", "name": "Rubric 权重归一", "sort": 10},
{"code": "VD_11_DIFFICULTY", "name": "难度梯度合理", "sort": 11},
{"code": "VD_12_DURATION", "name": "时长与容量匹配", "sort": 12},
{"code": "VD_13_ACCESSIBILITY", "name": "无障碍与降级态", "sort": 13},
{"code": "VD_14_TENANT_ISOLATION", "name": "租户隔离与写保护", "sort": 14}
] ]
}, },
{ {
"group_code": "pbl_quality_level", "group": "pbl_evidence_type",
"group_name": "PBL 蓝图质量等级5 级)", "group_name": "PBL证据类型",
"module": "pbl_appcodes", "module": "pbl_evidence",
"items_count": 5,
"items": [ "items": [
{"code": "L0_draft", "name": "L0 草稿级", "sort": 1, "remark": "未过校验"}, {"code": "EV_ARTIFACT", "name": "产出物", "sort": 1},
{"code": "L1_incomplete", "name": "L1 不完整", "sort": 2, "remark": "存在 blocker"}, {"code": "EV_DIALOGUE", "name": "对话记录", "sort": 2},
{"code": "L2_basic", "name": "L2 基础可用", "sort": 3, "remark": "无 blocker有 error"}, {"code": "EV_ACTION", "name": "操作行为", "sort": 3},
{"code": "L3_qualified", "name": "L3 合格", "sort": 4, "remark": "无 error允许 warning"}, {"code": "EV_DECISION", "name": "决策记录", "sort": 4},
{"code": "L4_excellent", "name": "L4 优秀", "sort": 5, "remark": "全维通过且加权得分达标"} {"code": "EV_COLLABORATION", "name": "协作贡献", "sort": 5},
{"code": "EV_REFLECTION", "name": "反思总结", "sort": 6},
{"code": "EV_ASSESSMENT", "name": "评估结果", "sort": 7},
{"code": "EV_RUNTIME_EVENT", "name": "运行时事件", "sort": 8},
{"code": "EV_DOCUMENT", "name": "文档附件", "sort": 9},
{"code": "EV_MEDIA", "name": "音视频媒体", "sort": 10}
] ]
}, },
{ {
"group_code": "pbl_subobj_type", "group": "pbl_assessment_level",
"group_name": "PBL 蓝图 7 类子对象类型", "group_name": "PBL评估等级",
"module": "pbl_appcodes", "module": "pbl_assessment",
"items_count": 7,
"items": [ "items": [
{"code": "objective", "name": "学习目标", "sort": 1, "remark": "泛化载荷 payload.objective"}, {"code": "AS_EXCELLENT", "name": "优秀", "sort": 1, "remark": ">=90"},
{"code": "stage", "name": "阶段", "sort": 2, "remark": "泛化载荷 payload.stage"}, {"code": "AS_GOOD", "name": "良好", "sort": 2, "remark": ">=80"},
{"code": "task", "name": "任务", "sort": 3, "remark": "泛化载荷 payload.task"}, {"code": "AS_PASS", "name": "合格", "sort": 3, "remark": ">=60"},
{"code": "artifact_spec", "name": "产出物规格", "sort": 4, "remark": "泛化载荷 payload.artifact_spec"}, {"code": "AS_WEAK", "name": "待改进", "sort": 4, "remark": ">=40"},
{"code": "rubric_ref", "name": "量规引用", "sort": 5, "remark": "泛化载荷 payload.rubric_ref"}, {"code": "AS_FAIL", "name": "不合格", "sort": 5, "remark": "<40"}
{"code": "resource_ref", "name": "资源引用", "sort": 6, "remark": "泛化载荷 payload.resource_ref"},
{"code": "assessment_plan", "name": "评估方案", "sort": 7, "remark": "泛化载荷 payload.assessment_plan"}
] ]
}, },
{ {
"group_code": "pbl_valid_dim", "group": "pbl_runtime_event_type",
"group_name": "PBL 校验 14 维", "group_name": "PBL运行时事件类型",
"module": "pbl_appcodes", "module": "pbl_runtime_ext",
"items_count": 14,
"items": [ "items": [
{"code": "D01_driving_question", "name": "驱动性问题清晰度", "sort": 1, "remark": "blocker 级"}, {"code": "RE_ENTITY_STATE", "name": "实体状态变更", "sort": 1},
{"code": "D02_objective_alignment", "name": "学习目标对齐", "sort": 2, "remark": "目标-任务-评估三向对齐"}, {"code": "RE_PLAYER_ACTION", "name": "玩家操作", "sort": 2},
{"code": "D03_stage_completeness", "name": "阶段完整性", "sort": 3, "remark": "阶段链无断裂"}, {"code": "RE_SCRIPT_TRIGGER", "name": "脚本触发", "sort": 3},
{"code": "D04_task_feasibility", "name": "任务可行性", "sort": 4, "remark": "课时与团队规模匹配"}, {"code": "RE_COLLISION", "name": "碰撞检测", "sort": 4},
{"code": "D05_artifact_spec", "name": "产出物规格明确性", "sort": 5, "remark": "每任务至少一个产出物规格"}, {"code": "RE_TIMER", "name": "定时器到期", "sort": 5},
{"code": "D06_rubric_coverage", "name": "量规覆盖度", "sort": 6, "remark": "目标被量规条目全覆盖"}, {"code": "RE_EVENT_DISPATCH", "name": "事件分发", "sort": 6},
{"code": "D07_resource_availability", "name": "资源可得性", "sort": 7, "remark": "资产引用可解析"}, {"code": "RE_BROADCAST", "name": "广播下发", "sort": 7},
{"code": "D08_assessment_plan", "name": "评估方案闭环", "sort": 8, "remark": "证据->量规->报告链路完整"}, {"code": "RE_SNAPSHOT", "name": "状态快照", "sort": 8},
{"code": "D09_duration_reasonableness", "name": "课时合理性", "sort": 9, "remark": "总课时与阶段和一致"}, {"code": "RE_PAUSE", "name": "会话暂停", "sort": 9},
{"code": "D10_team_collaboration", "name": "团队协作设计", "sort": 10, "remark": "角色分工与团队规模"}, {"code": "RE_RESUME", "name": "会话恢复", "sort": 10},
{"code": "D11_world_scene_binding", "name": "世界/场景绑定有效性", "sort": 11, "remark": "只读引用存在且可访问"}, {"code": "RE_ERROR_FALLBACK", "name": "错误降级", "sort": 11},
{"code": "D12_script_executability", "name": "脚本可执行性", "sort": 12, "remark": "script_engine 校验通过"}, {"code": "RE_SESSION_END", "name": "会话结束", "sort": 12}
{"code": "D13_evidence_traceability", "name": "证据可追溯性", "sort": 13, "remark": "幂等键规则可生成"},
{"code": "D14_compliance_safety", "name": "合规与安全", "sort": 14, "remark": "内容合规与写保护合规"}
] ]
}, },
{ {
"group_code": "pbl_agent_tool", "group": "pbl_agent_tool_enabled",
"group_name": "PBL Agent 工具白名单13 启用 / 9 禁用fail-closed", "group_name": "Agent工具-启用(13)",
"module": "pbl_appcodes", "module": "pbl_agent_runtime",
"items_count": 22,
"items": [ "items": [
{"code": "blueprint.read", "name": "读取蓝图", "sort": 1, "remark": "enabled"}, {"code": "AT_BLUEPRINT_READ", "name": "蓝图读取", "sort": 1, "status": "enabled"},
{"code": "blueprint.tree", "name": "读取蓝图子对象树", "sort": 2, "remark": "enabled"}, {"code": "AT_BLUEPRINT_WRITE", "name": "蓝图写入", "sort": 2, "status": "enabled"},
{"code": "subobject.read", "name": "读取子对象", "sort": 3, "remark": "enabled"}, {"code": "AT_SUBOBJECT_WRITE", "name": "子对象写入", "sort": 3, "status": "enabled"},
{"code": "subobject.upsert", "name": "写入子对象", "sort": 4, "remark": "enabled需编辑锁 + tenant_id"}, {"code": "AT_VALIDATE", "name": "蓝图校验", "sort": 4, "status": "enabled"},
{"code": "validation.run", "name": "触发校验", "sort": 5, "remark": "enabled"}, {"code": "AT_COMPILE", "name": "编译GameDefinition", "sort": 5, "status": "enabled"},
{"code": "validation.findings", "name": "读取校验发现", "sort": 6, "remark": "enabled"}, {"code": "AT_EVIDENCE_COLLECT", "name": "证据采集", "sort": 6, "status": "enabled"},
{"code": "compile.submit", "name": "提交编译", "sort": 7, "remark": "enabled前置校验必须 passed"}, {"code": "AT_ASSESS", "name": "评估打分", "sort": 7, "status": "enabled"},
{"code": "compile.status", "name": "查询编译状态", "sort": 8, "remark": "enabled"}, {"code": "AT_KDB_QUERY", "name": "KDB只读检索", "sort": 8, "status": "enabled"},
{"code": "evidence.collect", "name": "采集证据", "sort": 9, "remark": "enabled幂等键强制"}, {"code": "AT_DOMAIN_QUERY", "name": "基础域只读查询", "sort": 9, "status": "enabled"},
{"code": "evidence.query", "name": "查询证据", "sort": 10, "remark": "enabled"}, {"code": "AT_RUNTIME_QUERY", "name": "运行时状态查询", "sort": 10, "status": "enabled"},
{"code": "assessment.score", "name": "Rubric 加权评分", "sort": 11, "remark": "enabled"}, {"code": "AT_TEMPLATE_QUERY", "name": "模板检索", "sort": 11, "status": "enabled"},
{"code": "assessment.report", "name": "生成评估报告", "sort": 12, "remark": "enabled"}, {"code": "AT_ANALYTICS_QUERY", "name": "分析统计查询", "sort": 12, "status": "enabled"},
{"code": "analytics.snapshot_read", "name": "读取分析快照", "sort": 13, "remark": "enabled只读匿名聚合"}, {"code": "AT_GOVERNANCE_SUBMIT", "name": "治理审批提交", "sort": 13, "status": "enabled"}
{"code": "blueprint.delete", "name": "删除蓝图", "sort": 14, "remark": "disabledfail-closed 拒绝"},
{"code": "blueprint.publish", "name": "发布蓝图", "sort": 15, "remark": "disabled需人工拍板"},
{"code": "tenant.create", "name": "创建租户", "sort": 16, "remark": "disabled治理域人工操作"},
{"code": "rbac.role_grant", "name": "授予角色", "sort": 17, "remark": "disabledrbac 写保护"},
{"code": "world.write", "name": "写入世界", "sort": 18, "remark": "disabled复用域零改表"},
{"code": "scene.write", "name": "写入场景", "sort": 19, "remark": "disabled复用域零改表"},
{"code": "entity.write", "name": "写入实体", "sort": 20, "remark": "disabled复用域零改表"},
{"code": "kdb.write", "name": "写入知识库", "sort": 21, "remark": "disabledM7 零写入"},
{"code": "runtime.broadcast_force", "name": "强制广播", "sort": 22, "remark": "disabled广播仅由服务端权威时序触发"}
] ]
}, },
{ {
"group_code": "pbl_rel_artifact_kind", "group": "pbl_agent_tool_disabled",
"group_name": "PBL 关系类型与产出物种类", "group_name": "Agent工具-禁用(9,G6范围纪律)",
"module": "pbl_appcodes", "module": "pbl_agent_runtime",
"items_count": 12,
"items": [ "items": [
{"code": "rel.depends_on", "name": "依赖", "sort": 1, "remark": "子对象前置依赖"}, {"code": "AT_PBL_PUBLISH", "name": "蓝图发布(禁用)", "sort": 1, "status": "disabled"},
{"code": "rel.produces", "name": "产出", "sort": 2, "remark": "任务产出产出物规格"}, {"code": "AT_MARKETPLACE", "name": "Marketplace(禁用)", "sort": 2, "status": "disabled"},
{"code": "rel.assesses", "name": "评估", "sort": 3, "remark": "量规引用评估学习目标"}, {"code": "AT_SIM_RUN", "name": "sim.*(禁用)", "sort": 3, "status": "disabled"},
{"code": "rel.references", "name": "引用", "sort": 4, "remark": "只读引用复用域对象"}, {"code": "AT_KDB_WRITE", "name": "kdb.write(禁用)", "sort": 4, "status": "disabled"},
{"code": "rel.contains", "name": "包含", "sort": 5, "remark": "树形父子包含"}, {"code": "AT_RESEARCH_WRITE", "name": "research.write(禁用)", "sort": 5, "status": "disabled"},
{"code": "artifact.doc", "name": "文档", "sort": 6, "remark": "产出物种类"}, {"code": "AT_3D_AUTHOR", "name": "3D创作(禁用)", "sort": 6, "status": "disabled"},
{"code": "artifact.image", "name": "图片", "sort": 7, "remark": "产出物种类"}, {"code": "AT_FRAME_SYNC", "name": "帧同步CRDT(禁用)", "sort": 7, "status": "disabled"},
{"code": "artifact.model", "name": "三维模型", "sort": 8, "remark": "产出物种类glb/gltf"}, {"code": "AT_AGENT_AUTONOMY", "name": "全自主Agent(禁用)", "sort": 8, "status": "disabled"},
{"code": "artifact.audio", "name": "音频", "sort": 9, "remark": "产出物种类"}, {"code": "AT_SSO_ADMIN", "name": "SSO管理(禁用)", "sort": 9, "status": "disabled"}
{"code": "artifact.video", "name": "视频", "sort": 10, "remark": "产出物种类"},
{"code": "artifact.code", "name": "代码/脚本", "sort": 11, "remark": "产出物种类"},
{"code": "artifact.log", "name": "运行日志", "sort": 12, "remark": "产出物种类,运行时自动采集"}
] ]
} }
] ]

View File

@ -1,203 +1,137 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_appcodes 枚举幂等注入(6+2 组 74 项,自有表) """pbl_appcodes.seed —— 枚举字典幂等注入(零表,走平台 appcodes 字典
目标表appcodes/ appcodes_kv均为平台基础模块 appbase 既有表 设计约束docs/01-design/appcodes.md
本模块只写入 pbl_* 前缀的组与项不改动既有组不新增表 * 本模块 zero_table不建任何业务表字典项落在平台 appcodes 字典表内
* 幂等同一 (group, code) 已存在则跳过exists 判定重复执行结果一致
* 8 74 codes.json 完全一致 self_check.py 断言
* apps/pbls/build.sh 在部署阶段调用python -m pbl_appcodes.seed
* fail-closed平台字典接口不可用时抛错退出不静默跳过
幂等策略build.sh 可重复执行零差异 对外契约
1. group_code appcodes存在则比对 name/module不一致才 UPDATE一致则跳过 load_codes() -> dict 读取 codes.json
2. (group_code, code) appcodes_kv存在则比对 name/sort/remark一致跳过 iter_items(doc) -> generator 展平为 (group, group_name, item)
3. 全程不 DELETE避免误删平台既有字典 inject(dry_run=False) -> dict 执行注入返回统计
4. 返回统计 {groups_inserted, groups_updated, groups_skipped, items_*}重复执行应全为 skipped main() -> int CLI 入口0 成功 / 1 失败
租户维度枚举字典为平台级tenant_id='__system__'业务数据租户隔离由各模块 CRUD 保证
""" """
import json import json
import os import os
import sys
from pbl_common.kernel import ( HERE = os.path.dirname(os.path.abspath(__file__))
PblError, CODES_JSON = os.path.join(HERE, "codes.json")
current_tenant_id,
db,
fail,
now_str,
write_audit,
)
CODES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'codes.json') #: 平台字典表appcodes 基础模块所有,本模块只写字典项,不建表)
SYSTEM_TENANT = '__system__' DICT_TABLE = "app_codes"
GROUP_TABLE = 'appcodes'
ITEM_TABLE = 'appcodes_kv'
EXPECTED_GROUPS = 8
EXPECTED_ITEMS = 74
def load_codes(path=None): def load_codes(path=None):
"""读取 codes.json注入与自检共用同一事实源""" """读取 codes.json文件缺失或不可解析直接抛错fail-closed"""
target = path or CODES_FILE p = path or CODES_JSON
if not os.path.exists(target): if not os.path.isfile(p):
fail('PBL_E_PARAM_INVALID', detail={'reason': 'codes.json 不存在: %s' % target}) raise RuntimeError("codes.json 不存在: %s" % p)
with open(target, 'r', encoding='utf-8') as fp: with open(p, "r", encoding="utf-8") as f:
return json.load(fp) doc = json.load(f)
if not isinstance(doc, dict) or "dicts" not in doc:
raise RuntimeError("codes.json 结构非法:缺少 dicts 段")
return doc
def iter_groups(data): def iter_items(doc):
"""产出组字典序列(兼容 groups 为数组/字典两种写法)。""" """展平字典yield (group, group_name, item_dict)。"""
groups = data.get('groups') for d in doc.get("dicts") or []:
if isinstance(groups, list): group = d.get("group")
for grp in groups: group_name = d.get("group_name")
if isinstance(grp, dict): for it in d.get("items") or []:
yield grp yield group, group_name, it
return
if isinstance(groups, dict):
for key, grp in groups.items():
if isinstance(grp, dict):
grp.setdefault('group_code', key)
yield grp
def _diff_fields(old_row, new_fields): def _get_sor():
"""比对字段差异,返回需要更新的字段子集(无差异返回空 dict""" """取 sqlor 句柄;库名只来自 ServerEnv().get_module_dbname(),禁止硬编码。"""
changed = {}
for key, val in new_fields.items():
old_val = old_row.get(key)
if old_val is None and val is None:
continue
if str(old_val if old_val is not None else '') != str(val if val is not None else ''):
changed[key] = val
return changed
def seed_group(adp, grp, stats):
"""注入单个枚举组到 appcodes存在即比对无差异跳过"""
group_code = grp.get('group_code')
if not group_code:
fail('PBL_E_PARAM_INVALID', detail={'reason': '组缺少 group_code'})
fields = {
'code': group_code,
'name': grp.get('group_name') or group_code,
'module': grp.get('module') or 'pbl_appcodes',
'tenant_id': SYSTEM_TENANT,
'remark': grp.get('remark') or 'PBL Agent OS 枚举(%s' % group_code,
}
existing = adp.get(GROUP_TABLE, {'code': group_code}, tenant_id=SYSTEM_TENANT)
if existing:
changed = _diff_fields(existing, fields)
if changed:
changed['update_time'] = now_str()
adp.update(GROUP_TABLE, {'code': group_code}, changed, tenant_id=SYSTEM_TENANT)
stats['groups_updated'] += 1
else:
stats['groups_skipped'] += 1
return group_code
row = dict(fields)
row['create_time'] = now_str()
row['update_time'] = row['create_time']
adp.insert(GROUP_TABLE, row, tenant_id=SYSTEM_TENANT)
stats['groups_inserted'] += 1
return group_code
def seed_items(adp, group_code, items, stats):
"""注入组内枚举项到 appcodes_kv按 (group_code, code) 幂等)。"""
for item in items or []:
code = item.get('code')
if not code:
fail('PBL_E_PARAM_INVALID', detail={'reason': '%s 存在缺 code 的项' % group_code})
fields = {
'group_code': group_code,
'code': code,
'name': item.get('name') or code,
'sort': int(item.get('sort') or 0),
'remark': item.get('remark') or '',
'enabled': 1,
'tenant_id': SYSTEM_TENANT,
}
existing = adp.get(ITEM_TABLE, {'group_code': group_code, 'code': code},
tenant_id=SYSTEM_TENANT)
if existing:
changed = _diff_fields(existing, fields)
if changed:
changed['update_time'] = now_str()
adp.update(ITEM_TABLE, {'group_code': group_code, 'code': code}, changed,
tenant_id=SYSTEM_TENANT)
stats['items_updated'] += 1
else:
stats['items_skipped'] += 1
continue
row = dict(fields)
row['create_time'] = now_str()
row['update_time'] = row['create_time']
adp.insert(ITEM_TABLE, row, tenant_id=SYSTEM_TENANT)
stats['items_inserted'] += 1
def new_stats():
"""初始化注入统计。"""
return {
'groups_inserted': 0, 'groups_updated': 0, 'groups_skipped': 0,
'items_inserted': 0, 'items_updated': 0, 'items_skipped': 0,
'groups_total': 0, 'items_total': 0,
}
def seed_all(adapter=None, path=None, audit=True):
"""全量幂等注入 8 组 74 项,返回统计 dict。
重复执行时 inserted/updated 应全为 0skipped 等于总量零差异校验依据
"""
data = load_codes(path)
adp = adapter or db('pbl_appcodes')
stats = new_stats()
for grp in iter_groups(data):
group_code = seed_group(adp, grp, stats)
seed_items(adp, group_code, grp.get('items'), stats)
stats['groups_total'] += 1
stats['items_total'] += len(grp.get('items') or [])
if stats['groups_total'] != EXPECTED_GROUPS:
fail('PBL_E_PARAM_INVALID',
detail={'reason': '组数契约不符', 'expect': EXPECTED_GROUPS, 'got': stats['groups_total']})
if stats['items_total'] != EXPECTED_ITEMS:
fail('PBL_E_PARAM_INVALID',
detail={'reason': '项数契约不符', 'expect': EXPECTED_ITEMS, 'got': stats['items_total']})
if audit:
try:
write_audit('seed', object_type='pbl_appcodes', object_code='codes.json',
result='success', detail=stats, tenant_id=SYSTEM_TENANT, adapter=adp)
except PblError:
pass # 审计降级不阻断种子注入
stats['idempotent'] = (stats['groups_inserted'] == 0 and stats['groups_updated'] == 0
and stats['items_inserted'] == 0 and stats['items_updated'] == 0)
return stats
def list_group_codes(adapter=None):
"""返回 codes.json 中全部组编码(供 build.sh 校验与 api 下拉)。"""
data = load_codes()
return [grp.get('group_code') for grp in iter_groups(data)]
def get_items(group_code, adapter=None):
"""按组编码取枚举项(优先读库,库不可用时回落 codes.json 离线兜底)。"""
adp = adapter or db('pbl_appcodes')
try: try:
rows = adp.query(ITEM_TABLE, where={'group_code': group_code}, from pbl_common.kernel import get_dbname
order_by='sort', limit=200, tenant_id=SYSTEM_TENANT) dbname = get_dbname("pbl_appcodes")
if rows: except Exception:
return rows try:
except PblError: from appbase.serverenv import ServerEnv
pass dbname = ServerEnv().get_module_dbname("pbl_appcodes")
data = load_codes() except Exception as e:
for grp in iter_groups(data): raise RuntimeError("无法解析 pbl_appcodes 库名ServerEnv 不可用): %s" % e)
if grp.get('group_code') == group_code: import sqlor
return list(grp.get('items') or []) return sqlor.sor(dbname) if hasattr(sqlor, "sor") else sqlor.Sor(dbname)
return []
def _already_exists(sor, group, code):
"""幂等判定:同 (group, code) 已存在则返回 True。"""
rows = sor.R(DICT_TABLE, {"group_name": group, "code": code}, limit=1)
return bool(rows)
def inject(dry_run=False, verbose=True):
"""执行幂等注入。
返回 {'total': n, 'inserted': i, 'skipped': s, 'failed': f, 'dry_run': bool}
dry_run=True 时只统计不落库 build.sh 预检
"""
doc = load_codes()
stat = {"total": 0, "inserted": 0, "skipped": 0, "failed": 0, "dry_run": bool(dry_run)}
sor = None if dry_run else _get_sor()
for group, group_name, it in iter_items(doc):
stat["total"] += 1
code = it.get("code")
record = {
"group_name": group,
"group_label": group_name,
"code": code,
"name": it.get("name"),
"sort": it.get("sort"),
"module": (doc.get("_meta") or {}).get("module") or "pbl_appcodes",
"status": it.get("status") or "enabled",
"remark": it.get("remark") or "",
}
if dry_run:
stat["inserted"] += 1
if verbose:
print("[DRY] %s.%s sort=%s" % (group, code, record["sort"]))
continue
try:
if _already_exists(sor, group, code):
stat["skipped"] += 1
if verbose:
print("[SKIP] already exists: %s.%s" % (group, code))
continue
sor.C(DICT_TABLE, record)
stat["inserted"] += 1
if verbose:
print("[ADD ] %s.%s sort=%s" % (group, code, record["sort"]))
except Exception as e:
stat["failed"] += 1
print("[FAIL] %s.%s -> %s" % (group, code, e), file=sys.stderr)
return stat
def main(argv=None):
argv = list(argv if argv is not None else sys.argv[1:])
dry = "--dry-run" in argv
quiet = "--quiet" in argv
try:
stat = inject(dry_run=dry, verbose=not quiet)
except Exception as e:
print("SEED pbl_appcodes: FAIL (%s)" % e, file=sys.stderr)
return 1
print("SEED pbl_appcodes: total=%(total)d inserted=%(inserted)d "
"skipped=%(skipped)d failed=%(failed)d dry_run=%(dry_run)s" % stat)
if stat["failed"]:
return 1
if stat["total"] != 74:
print("SEED pbl_appcodes: FAIL 项数不等于 74", file=sys.stderr)
return 1
print("SEED pbl_appcodes: PASS 74/74")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,181 +1,178 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_appcodes 数量契约自检(可独立运行,不依赖数据库)。 """pbl_appcodes.self_check —— 枚举字典契约自检(可独立运行)
核验项对齐 docs/01-design/appcodes.md QC 退回意见 #15 运行方式无需数据库无需平台环境
1. _meta.groups == 8_meta.items == 74_meta.dialect == 'mariadb' cd modules/pbl_appcodes && python -m pbl_appcodes.self_check
2. 实际组数 == _meta.groups实际项数合计 == _meta.items python modules/pbl_appcodes/pbl_appcodes/self_check.py
3. 每组声明 items == 该组实际项数
4. 全部编码group_code item code全局唯一
5. 每组内 sort 1 开始连续递增无跳号无重复
6. 每项必含 code/name/sort 三字段且类型合法
运行 期望输出末行
python -m pbl_appcodes.self_check
python modules/pbl_appcodes/pbl_appcodes/self_check.py
输出成功
SELF_CHECK pbl_appcodes: PASS 74/74 SELF_CHECK pbl_appcodes: PASS 74/74
退出码0=PASS1=FAILbuild.sh 据此 fail-fast
校验项对齐 docs/01-design/appcodes.md QC 退回意见 #11/#12
C1 codes.json 可解析_meta.groups == 8_meta.items == 74_meta.dialect == 'mariadb'
C2 实际组数 == _meta.groups实际项数合计 == _meta.items
C3 编码全局唯一跨组不得重复
C4 每组 sort 1 连续递增到 n无跳号无重复 0/负数
C5 每项必备字段 code/name/sort 齐全且类型正确code 形如大写标识符
C6 组名唯一group 字段为合法标识符
C7 Agent 工具启停契约enabled 13 disabled 9 status 与组语义一致
C8 零表约束本模块不得声明任何 CREATE TABLE seed.py 源码断言
退出码全部通过 0任一失败 1fail-closed build.sh / CI 直接判定
""" """
import json import json
import os import os
import re
import sys import sys
CODES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'codes.json') HERE = os.path.dirname(os.path.abspath(__file__))
CODES_JSON = os.path.join(HERE, "codes.json")
SEED_PY = os.path.join(HERE, "seed.py")
EXPECTED_GROUPS = 8 EXPECT_GROUPS = 8
EXPECTED_ITEMS = 74 EXPECT_ITEMS = 74
EXPECTED_DIALECT = 'mariadb' EXPECT_DIALECT = "mariadb"
REQUIRED_ITEM_FIELDS = ('code', 'name', 'sort') EXPECT_ENABLED = 13
EXPECT_DISABLED = 9
GROUP_ENABLED = "pbl_agent_tool_enabled"
GROUP_DISABLED = "pbl_agent_tool_disabled"
CODE_RE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
IDENT_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
_results = []
def load_codes(path=None): def _check(name, ok, detail=""):
"""读取 codes.json文件缺失或非法 JSON 直接抛错fail-fast不静默""" _results.append((name, bool(ok), detail))
target = path or CODES_FILE return bool(ok)
if not os.path.exists(target):
raise IOError('codes.json 不存在: %s' % target)
with open(target, 'r', encoding='utf-8') as fp:
return json.load(fp)
def check_meta(data, errors): def load_codes():
"""校验 _meta 段声明值。""" if not os.path.isfile(CODES_JSON):
meta = data.get('_meta') or {} raise SystemExit("FATAL: codes.json 不存在: %s" % CODES_JSON)
if meta.get('dialect') != EXPECTED_DIALECT: with open(CODES_JSON, "r", encoding="utf-8") as f:
errors.append('_meta.dialect 应为 %s,实际 %r' % (EXPECTED_DIALECT, meta.get('dialect'))) return json.load(f)
if meta.get('groups') != EXPECTED_GROUPS:
errors.append('_meta.groups 应为 %d,实际 %r' % (EXPECTED_GROUPS, meta.get('groups')))
if meta.get('items') != EXPECTED_ITEMS:
errors.append('_meta.items 应为 %d,实际 %r' % (EXPECTED_ITEMS, meta.get('items')))
return meta
def iter_groups(data): def run_checks():
"""产出 (group_code, group_dict) 序列,跳过 _meta 段。""" doc = load_codes()
groups = data.get('groups') meta = doc.get("_meta") or {}
if isinstance(groups, list): dicts = doc.get("dicts") or []
for grp in groups:
if isinstance(grp, dict):
yield grp.get('group_code') or grp.get('code') or '', grp
return
if isinstance(groups, dict):
for key, grp in groups.items():
if isinstance(grp, dict):
yield grp.get('group_code') or key, grp
return
# 兼容平铺结构:顶层除 _meta 外每个键即一组
for key, val in data.items():
if key.startswith('_'):
continue
if isinstance(val, dict):
yield val.get('group_code') or key, val
elif isinstance(val, list):
yield key, {'group_code': key, 'items': val}
# C1 _meta 契约
_check("C1.meta.groups==8", meta.get("groups") == EXPECT_GROUPS,
"actual=%r" % (meta.get("groups"),))
_check("C1.meta.items==74", meta.get("items") == EXPECT_ITEMS,
"actual=%r" % (meta.get("items"),))
_check("C1.meta.dialect==mariadb", meta.get("dialect") == EXPECT_DIALECT,
"actual=%r" % (meta.get("dialect"),))
_check("C1.meta.idempotent", meta.get("idempotent") is True)
_check("C1.meta.zero_table", meta.get("zero_table") is True)
def check_group(group_code, grp, errors, seen_group_codes, seen_item_codes): # C2 实际数量
"""校验单组组编码唯一、声明项数与实际一致、项字段齐全、sort 连续、项编码唯一。""" actual_groups = len(dicts)
if not group_code: actual_items = sum(len(d.get("items") or []) for d in dicts)
errors.append('存在缺少 group_code 的组') _check("C2.actual_groups==8", actual_groups == EXPECT_GROUPS, "actual=%d" % actual_groups)
elif group_code in seen_group_codes: _check("C2.actual_items==74", actual_items == EXPECT_ITEMS, "actual=%d" % actual_items)
errors.append('组编码重复: %s' % group_code)
# C3 编码全局唯一
seen = {}
dup = []
for d in dicts:
for it in (d.get("items") or []):
c = it.get("code")
if c in seen:
dup.append("%s(%s/%s)" % (c, seen[c], d.get("group")))
else:
seen[c] = d.get("group")
_check("C3.code_globally_unique", not dup, "dup=%s" % dup)
# C4 每组 sort 1..n 连续
bad_sort = []
for d in dicts:
items = d.get("items") or []
sorts = [it.get("sort") for it in items]
expect = list(range(1, len(items) + 1))
if sorts != expect:
bad_sort.append("%s:%s!=%s" % (d.get("group"), sorts, expect))
_check("C4.sort_continuous_1..n", not bad_sort, "bad=%s" % bad_sort)
# C5 项字段完备 + code 形态
bad_item = []
for d in dicts:
for it in (d.get("items") or []):
if not isinstance(it.get("code"), str) or not CODE_RE.match(it["code"]):
bad_item.append("code:%r@%s" % (it.get("code"), d.get("group")))
if not isinstance(it.get("name"), str) or not it["name"].strip():
bad_item.append("name:%r@%s" % (it.get("name"), d.get("group")))
if not isinstance(it.get("sort"), int) or isinstance(it.get("sort"), bool):
bad_item.append("sort:%r@%s" % (it.get("sort"), d.get("group")))
_check("C5.item_fields_valid", not bad_item, "bad=%s" % bad_item[:5])
# C6 组标识唯一且合法
groups = [d.get("group") for d in dicts]
bad_group = [g for g in groups if not isinstance(g, str) or not IDENT_RE.match(g)]
_check("C6.group_ident_valid", not bad_group, "bad=%s" % bad_group)
_check("C6.group_unique", len(set(groups)) == len(groups), "groups=%s" % groups)
names = [d.get("group_name") for d in dicts]
_check("C6.group_name_unique", len(set(names)) == len(names))
_check("C6.group_name_nonempty", all(isinstance(n, str) and n.strip() for n in names))
# C7 Agent 工具启停契约 13/9
by_group = {d.get("group"): (d.get("items") or []) for d in dicts}
en = by_group.get(GROUP_ENABLED, [])
dis = by_group.get(GROUP_DISABLED, [])
_check("C7.enabled_count==13", len(en) == EXPECT_ENABLED, "actual=%d" % len(en))
_check("C7.disabled_count==9", len(dis) == EXPECT_DISABLED, "actual=%d" % len(dis))
_check("C7.enabled_status_all_enabled",
all(it.get("status") == "enabled" for it in en) if en else False)
_check("C7.disabled_status_all_disabled",
all(it.get("status") == "disabled" for it in dis) if dis else False)
_check("C7.tool_total==22", len(en) + len(dis) == 22,
"actual=%d" % (len(en) + len(dis)))
# C8 零表约束seed.py 不得含 CREATE TABLE
ddl_hit = []
if os.path.isfile(SEED_PY):
with open(SEED_PY, "r", encoding="utf-8") as f:
src = f.read()
for kw in ("CREATE TABLE", "create table", "ALTER TABLE", "DROP TABLE"):
if kw in src:
ddl_hit.append(kw)
_check("C8.seed_has_inject_entry", "def inject" in src or "def seed" in src,
"seed.py 缺少注入入口函数")
_check("C8.seed_idempotent_guard",
("already" in src.lower()) or ("exists" in src.lower()) or ("ON DUPLICATE" in src),
"seed.py 未见幂等判定")
else: else:
seen_group_codes.add(group_code) _check("C8.seed_py_exists", False, "seed.py 不存在")
_check("C8.zero_table_no_ddl", not ddl_hit, "hit=%s" % ddl_hit)
items = grp.get('items') or [] return actual_items
if not isinstance(items, list):
errors.append('%s 的 items 不是数组' % group_code)
return 0
declared = grp.get('items_count')
if declared is not None and declared != len(items):
errors.append('%s 声明 items_count=%s 与实际 %d 不一致' % (group_code, declared, len(items)))
sorts = []
for idx, item in enumerate(items):
if not isinstance(item, dict):
errors.append('%s%d 项不是对象' % (group_code, idx))
continue
for field in REQUIRED_ITEM_FIELDS:
if field not in item:
errors.append('%s%d 项缺字段 %s' % (group_code, idx, field))
code = item.get('code')
if not isinstance(code, str) or not code:
errors.append('%s%d 项 code 非法: %r' % (group_code, idx, code))
elif code in seen_item_codes:
errors.append('项编码重复: %s(组 %s' % (code, group_code))
else:
seen_item_codes.add(code)
if not isinstance(item.get('name'), str) or not item.get('name'):
errors.append('%s%s 的 name 非法' % (group_code, code))
sort_val = item.get('sort')
if not isinstance(sort_val, int) or isinstance(sort_val, bool):
errors.append('%s%s 的 sort 非整数: %r' % (group_code, code, sort_val))
else:
sorts.append(sort_val)
check_sort_continuous(group_code, sorts, errors)
return len(items)
def check_sort_continuous(group_code, sorts, errors): def main():
"""sort 必须为 1..n 连续(允许输入乱序,排序后逐位比对)。""" total_items = run_checks()
if not sorts: failed = [r for r in _results if not r[1]]
return print("=" * 68)
ordered = sorted(sorts) print("SELF_CHECK pbl_appcodes —— 枚举字典契约自检")
if len(set(ordered)) != len(ordered): print("codes.json: %s" % CODES_JSON)
errors.append('%s sort 存在重复值' % group_code) print("-" * 68)
return for name, ok, detail in _results:
expected = list(range(1, len(ordered) + 1)) flag = "PASS" if ok else "FAIL"
if ordered != expected: line = "[%s] %s" % (flag, name)
errors.append('%s sort 不连续:期望 %s,实际 %s' % (group_code, expected, ordered)) if not ok and detail:
line += " -> %s" % detail
print(line)
print("-" * 68)
print("检查项: %d 通过 / %d 失败;字典项总数: %d" % (len(_results) - len(failed), len(failed), total_items))
if failed:
print("SELF_CHECK pbl_appcodes: FAIL %d/%d" % (total_items - len(failed), total_items))
return 1
print("SELF_CHECK pbl_appcodes: PASS %d/%d" % (total_items, total_items))
return 0
def run_self_check(path=None, verbose=False): if __name__ == "__main__":
"""执行全部核验,返回 (ok, total_items, errors)。"""
errors = []
try:
data = load_codes(path)
except Exception as exc:
return False, 0, ['codes.json 读取失败: %s' % exc]
check_meta(data, errors)
seen_group_codes = set()
seen_item_codes = set()
group_count = 0
item_total = 0
for group_code, grp in iter_groups(data):
group_count += 1
item_total += check_group(group_code, grp, errors, seen_group_codes, seen_item_codes)
if verbose:
print(' group %-28s items=%d' % (group_code, len(grp.get('items') or [])))
if group_count != EXPECTED_GROUPS:
errors.append('实际组数 %d != 契约 %d' % (group_count, EXPECTED_GROUPS))
if item_total != EXPECTED_ITEMS:
errors.append('实际项数 %d != 契约 %d' % (item_total, EXPECTED_ITEMS))
return (len(errors) == 0), item_total, errors
def main(argv=None):
argv = list(argv if argv is not None else sys.argv[1:])
verbose = '-v' in argv or '--verbose' in argv
path = None
for arg in argv:
if arg.endswith('.json'):
path = arg
ok, total, errors = run_self_check(path=path, verbose=verbose)
if ok:
print('SELF_CHECK pbl_appcodes: PASS %d/%d' % (total, EXPECTED_ITEMS))
return 0
print('SELF_CHECK pbl_appcodes: FAIL %d/%d' % (total, EXPECTED_ITEMS))
for msg in errors:
print(' - %s' % msg)
return 1
if __name__ == '__main__':
sys.exit(main()) sys.exit(main())