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

This commit is contained in:
agent.develop 2026-09-18 12:18:08 +08:00
parent d40bc7ca8f
commit 80ff678332
4 changed files with 1791 additions and 232 deletions

View File

@ -0,0 +1,215 @@
{
"summary": [
{
"name": "pbl_compile_task",
"comment": "编译任务approval_id 门禁F-CP-01/US-10",
"module": "pbl_compiler",
"engine": "mariadb",
"charset": "utf8mb4",
"tenant_scoped": true,
"append_only": false
}
],
"fields": [
{
"name": "tenant_id",
"comment": "租户ID(强制打头)",
"null": false,
"type": "str",
"length": 64
},
{
"name": "id",
"comment": "主键",
"null": false,
"type": "int",
"length": 20,
"unsigned": true,
"auto_increment": true
},
{
"name": "task_no",
"comment": "任务流水号(确定性派生:CT+blueprint_id+version+seq)",
"null": false,
"type": "str",
"length": 64
},
{
"name": "blueprint_id",
"comment": "蓝图ID",
"null": false,
"type": "int",
"length": 20,
"unsigned": true
},
{
"name": "blueprint_version",
"comment": "蓝图版本(须已审批)",
"null": false,
"type": "int",
"length": 11
},
{
"name": "compiler_version",
"comment": "编译器版本号",
"null": false,
"type": "str",
"length": 32
},
{
"name": "ruleset_version",
"comment": "规则集版本",
"null": true,
"type": "str",
"length": 32
},
{
"name": "status",
"comment": "appcodes:pbl_compile_status(pending/running/success/failed)",
"null": false,
"type": "str",
"length": 32
},
{
"name": "approval_id",
"comment": "关联审批记录(未审批拒绝 F-CP-01)",
"null": true,
"type": "int",
"length": 20,
"unsigned": true
},
{
"name": "game_def_id",
"comment": "产出的 Game Definition ID(success 时回填)",
"null": true,
"type": "int",
"length": 20,
"unsigned": true
},
{
"name": "content_fingerprint",
"comment": "产物 SHA-256 指纹(success 时回填,US-11 比对锚点)",
"null": true,
"type": "str",
"length": 64
},
{
"name": "error_code",
"comment": "失败错误码(PBL_E_*)",
"null": true,
"type": "str",
"length": 64
},
{
"name": "error_msg",
"comment": "失败原因",
"null": true,
"type": "str",
"length": 2048
},
{
"name": "triggered_by",
"comment": "触发人",
"null": false,
"type": "str",
"length": 64
},
{
"name": "duration_ms",
"comment": "编译耗时(易变字段,不参与指纹)",
"null": false,
"type": "int",
"length": 11
},
{
"name": "started_at",
"comment": "开始时间",
"null": true,
"type": "datetime"
},
{
"name": "finished_at",
"comment": "结束时间",
"null": true,
"type": "datetime"
},
{
"name": "created_at",
"comment": "创建时间(应用层写入)",
"null": false,
"type": "datetime"
},
{
"name": "updated_at",
"comment": "更新时间(应用层写入)",
"null": false,
"type": "datetime"
}
],
"indexes": [
{
"name": "PRIMARY",
"unique": true,
"fields": [
"id"
]
},
{
"name": "uk_ct_task_no",
"unique": true,
"fields": [
"tenant_id",
"task_no"
]
},
{
"name": "idx_ct_blueprint",
"unique": false,
"fields": [
"tenant_id",
"blueprint_id",
"blueprint_version"
]
},
{
"name": "idx_ct_status",
"unique": false,
"fields": [
"tenant_id",
"status"
]
},
{
"name": "idx_ct_fingerprint",
"unique": false,
"fields": [
"tenant_id",
"content_fingerprint"
]
}
],
"codes": [
{
"name": "pbl_compile_status",
"comment": "编译任务状态(第29.6章)",
"items": [
{
"code": "pending",
"name": "待执行"
},
{
"code": "running",
"name": "编译中"
},
{
"code": "success",
"name": "成功"
},
{
"code": "failed",
"name": "失败"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

207
pbl_compiler/canonical.py Normal file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_compiler.canonical —— 29.6 确定性编译内核(规范化 JSON + SHA-256 指纹)。
本文件是 **纯函数** 模块 import DB import 时间/随机不调用任何模型
US-11同输入重编译指纹相等的全部确定性保证都落在这里
1. **键序固定**``sort_keys=True`` + 递归 ``deep_sort``list[dict] 按规范化串排序
2. **无空白**``separators=(',', ':')``
3. **UTF-8 稳定**``ensure_ascii=False``编码固定 ``utf-8``
4. **浮点定点**``FLOAT_PRECISION=6`` 位量化杜绝 0.1+0.2 类表示漂移
NaN/Infinity 一律拒绝``allow_nan=False`` PBL_E_VALIDATION
5. **无时间戳/无随机**``strip_volatile`` 递归剔除易变键createdAt/compiledAt/
duration_ms/task_no/id/...指纹只覆盖内容键
6. **registry_hash**能力注册表快照的规范化哈希 manifest 参与指纹
保证能力集变更 指纹变更可追溯F-CP-05
禁止在本模块引入 ``time`` / ``datetime.now`` / ``random`` / ``uuid`` / 网络 / LLM
"""
from __future__ import annotations
import hashlib
import json
__all__ = [
'CANONICAL_ENCODING', 'FLOAT_PRECISION', 'VOLATILE_KEYS', 'GD_SCHEMA',
'CanonicalError', 'normalize', 'deep_sort', 'strip_volatile',
'canonical_json', 'canonical_bytes', 'sha256_fingerprint',
'registry_hash', 'stable_id', 'short_hash',
]
#: 规范化输出编码(固定,不可配置——配置化即破坏可复现性)
CANONICAL_ENCODING = 'utf-8'
#: 浮点量化精度(小数位)
FLOAT_PRECISION = 6
#: Game Definition schema 标识(第 9 章)
GD_SCHEMA = 'pbl.game_definition.v1'
#: 易变字段:出现在 GD 任意层级都 **不参与指纹计算**29.6「无时间戳注入」)
VOLATILE_KEYS = frozenset({
'createdAt', 'created_at', 'compiledAt', 'compiled_at', 'updatedAt',
'updated_at', 'finishedAt', 'finished_at', 'startedAt', 'started_at',
'generatedAt', 'generated_at', 'timestamp', 'ts', 'duration_ms',
'durationMs', 'latency_ms', 'task_no', 'taskNo', 'id', 'pk',
'trace_id', 'request_id', 'nonce',
})
class CanonicalError(ValueError):
"""规范化失败(不可序列化 / 非有限浮点)——上层映射 PBL_E_VALIDATION。"""
def _norm_float(value):
"""浮点定点化:量化到 FLOAT_PRECISION 位,返回 int整值或 float。
``round()`` IEEE-754 double 是确定的Python ``repr`` 走最短往返表示
因此同值在任何平台/任何次运行都产出同一串字符
"""
if value != value or value in (float('inf'), float('-inf')):
raise CanonicalError('非有限浮点不可规范化: %r' % (value,))
q = round(float(value), FLOAT_PRECISION)
if q == int(q) and abs(q) < 1e15:
return int(q)
return q
def normalize(obj, _depth=0):
"""递归规范化为「只含 JSON 原生类型」的结构。
- dict键转 str值递归规范化
- list/tuple逐项递归
- set/frozenset list 后按规范化串排序集合无序 必须排序才确定
- float``_norm_float`` 定点化bool 先于 int 判定bool int 子类
- byteshexNone/str/int原样
- 其它对象``str()`` 兜底datetime 等易变类型应在上游被 strip_volatile 剔除
"""
if _depth > 64:
raise CanonicalError('嵌套过深(>64疑似循环引用')
if obj is None or isinstance(obj, str):
return obj
if isinstance(obj, bool):
return obj
if isinstance(obj, int):
return obj
if isinstance(obj, float):
return _norm_float(obj)
if isinstance(obj, (bytes, bytearray)):
return bytes(obj).hex()
if isinstance(obj, dict):
out = {}
for k in obj:
out[str(k)] = normalize(obj[k], _depth + 1)
return out
if isinstance(obj, (set, frozenset)):
items = [normalize(x, _depth + 1) for x in obj]
return sorted(items, key=lambda x: json.dumps(
x, sort_keys=True, ensure_ascii=False, separators=(',', ':'), default=str))
if isinstance(obj, (list, tuple)):
return [normalize(x, _depth + 1) for x in obj]
return str(obj)
def _sort_key(value):
return json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(',', ':'), default=str)
def deep_sort(obj):
"""深度排序dict 键序由 ``sort_keys`` 保证list[dict] 按内容规范化串排序。
只对元素全为 dict list 排序蓝图子对象集合无序入库排序后编译才确定
含标量/混合元素的 list 保持原序顺序本身是语义 rubric 维度权重序列
"""
if isinstance(obj, dict):
return {k: deep_sort(obj[k]) for k in sorted(obj.keys())}
if isinstance(obj, list):
items = [deep_sort(x) for x in obj]
if items and all(isinstance(x, dict) for x in items):
return sorted(items, key=_sort_key)
return items
return obj
def strip_volatile(obj):
"""递归剔除 VOLATILE_KEYS时间戳/耗时/流水号/自增 id 等易变字段)。
指纹只覆盖内容键 这是manifest.createdAt 不参与指纹计算的落地点
"""
if isinstance(obj, dict):
return {k: strip_volatile(v) for k, v in obj.items() if k not in VOLATILE_KEYS}
if isinstance(obj, list):
return [strip_volatile(x) for x in obj]
return obj
def canonical_json(obj, strip=True, sort_lists=True):
"""规范化 JSON 串:键序固定 / 无空白 / UTF-8 / 浮点定点 / 剔除易变键。
:param strip: 是否剔除易变键指纹计算必须 True落库正文用 False 保留 createdAt
:param sort_lists: 是否对 list[dict] 按内容排序指纹计算必须 True
"""
data = normalize(obj)
if strip:
data = strip_volatile(data)
if sort_lists:
data = deep_sort(data)
try:
return json.dumps(data, sort_keys=True, ensure_ascii=False,
separators=(',', ':'), allow_nan=False)
except ValueError as exc:
raise CanonicalError('规范化 JSON 失败: %s' % exc)
def canonical_bytes(obj, strip=True, sort_lists=True):
"""规范化 JSON 的 UTF-8 字节串(指纹输入)。"""
return canonical_json(obj, strip=strip, sort_lists=sort_lists).encode(CANONICAL_ENCODING)
def sha256_fingerprint(obj):
"""内容指纹:``sha256(canonical_json(strip_volatile(obj)))`` 的 64 位十六进制串。
同输入同蓝图版本快照 + compilerVersion + ruleset + 同能力注册表
同指纹任何内容变更 指纹变更US-11 / F-CP-03
"""
return hashlib.sha256(canonical_bytes(obj, strip=True, sort_lists=True)).hexdigest()
def short_hash(text, length=16):
"""短哈希(规则集/注册表摘要用,长度固定,确定性)。"""
if not isinstance(text, (str, bytes)):
text = canonical_json(text)
if isinstance(text, str):
text = text.encode(CANONICAL_ENCODING)
return hashlib.sha256(text).hexdigest()[:length]
def registry_hash(capabilities):
"""能力注册表哈希:对 pbl.* 能力清单key+version+args_schema取规范化 SHA-256。
GD ``manifest.registryHash`` 并参与 content_fingerprint 能力集变更必然
导致指纹变更编译器版本差异可查F-CP-05 / diff_compiler_versions
"""
items = []
for cap in (capabilities or []):
if isinstance(cap, str):
items.append({'capability_key': cap, 'version_no': 1, 'args_schema_hash': ''})
continue
key = cap.get('capability_key') or cap.get('key') or cap.get('code') or ''
schema = cap.get('args_schema_json') or cap.get('args_schema') or cap.get('schema') or ''
items.append({
'capability_key': str(key),
'version_no': int(cap.get('version_no') or cap.get('version') or 1),
'args_schema_hash': short_hash(schema if isinstance(schema, str)
else canonical_json(schema), 16),
'permission_required': str(cap.get('permission_required') or ''),
'is_enabled': bool(cap.get('is_enabled', True)),
})
items.sort(key=_sort_key)
return hashlib.sha256(canonical_bytes(items)).hexdigest()
def stable_id(blueprint_id, version_no, kind, seq):
"""确定性 ID 派生:``{blueprint_id}:{version_no}:{kind}:{seq:05d}``。
29.6无随机GD 内一切 id 都由输入派生禁用 uuid/randomUS-11
"""
return '%s:%s:%s:%05d' % (blueprint_id, int(version_no), kind, int(seq))

534
pbl_compiler/gd_builder.py Normal file
View File

@ -0,0 +1,534 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_compiler.gd_builder —— 蓝图快照 → Game Definition第 9 章 10 顶层键)。
**纯函数**入参只有 (snapshot, ctx)ctx 里的一切值都由调用方从 DB 读出后传入
本模块不碰 DB / 不碰时间 / 不碰随机 / 不调 LLM29.6 + 6.2禁止编译调 LLM
10 顶层键顺序即 GD_SCHEMA 声明顺序canonical_json 会再按字典序排
manifest / pbl / world / scenes / entities / events / states /
capabilities / assessment / assets
所有集合一律先按稳定键排序再遍历所有 id ``canonical.stable_id`` 派生
保证同输入 GD 同指纹US-11
"""
from __future__ import annotations
import json
from pbl_compiler.canonical import (GD_SCHEMA, canonical_json, registry_hash,
sha256_fingerprint, short_hash, stable_id)
__all__ = ['GD_TOP_KEYS', 'build_game_definition', 'extract_snapshot_objects',
'build_manifest', 'build_pbl', 'build_world', 'build_scenes',
'build_entities', 'build_events', 'build_states',
'build_capabilities', 'build_assessment', 'build_assets',
'fingerprint_of']
#: 第 9 章 GD 10 顶层键(缺任一即编译失败 PBL_E_COMPILE
GD_TOP_KEYS = ('manifest', 'pbl', 'world', 'scenes', 'entities', 'events',
'states', 'capabilities', 'assessment', 'assets')
def _loads(value, default=None):
"""容错 JSON 解析:已是 dict/list 原样返回,空值返回 default。"""
if isinstance(value, (dict, list)):
return value
if value in (None, '', b''):
return default if default is not None else []
try:
return json.loads(value)
except (ValueError, TypeError):
return default if default is not None else []
def _sorted_rows(value):
"""集合稳定化:解析 → 规范化串排序入库顺序不可依赖29.6)。"""
rows = _loads(value, [])
if isinstance(rows, dict):
rows = [dict(v, _key=k) for k, v in sorted(rows.items())]
if not isinstance(rows, list):
rows = [rows]
return sorted(rows, key=lambda r: canonical_json(r, strip=False, sort_lists=False))
def _uid(row, fallback_kind, seq):
"""行内稳定标识:优先 obj_uid/code其次内容短哈希确定性不用 uuid"""
if not isinstance(row, dict):
return '%s:%05d' % (fallback_kind, seq)
for key in ('obj_uid', 'code', 'uid', 'key', 'name'):
val = row.get(key)
if val not in (None, ''):
return str(val)
return '%s:%s' % (fallback_kind, short_hash(canonical_json(row), 12))
def _text(row, *keys, default=''):
for k in keys:
v = row.get(k) if isinstance(row, dict) else None
if v not in (None, ''):
return str(v)
return default
def _int(row, *keys, default=0):
for k in keys:
v = row.get(k) if isinstance(row, dict) else None
if v not in (None, ''):
try:
return int(v)
except (TypeError, ValueError):
continue
return default
def extract_snapshot_objects(snapshot):
"""从蓝图版本快照里抽出 7 类子对象(键名兼容 M1a/M1b 两种落库形态)。
返回 dictgoals/project/problem/missions/roles/artifacts/evidence_specs/
rubrics/events/scenes/assets 每项都是已排序的 list
"""
snap = snapshot if isinstance(snapshot, dict) else {}
content = snap.get('content') or snap.get('content_json') or snap
if isinstance(content, str):
content = _loads(content, {})
if not isinstance(content, dict):
content = {}
sub = content.get('subobjects') or content.get('sub_objects') or {}
if isinstance(sub, list):
grouped = {}
for item in sub:
if not isinstance(item, dict):
continue
grouped.setdefault(str(item.get('obj_type') or item.get('type') or 'other'),
[]).append(item)
sub = grouped
if not isinstance(sub, dict):
sub = {}
def pick(*names):
for n in names:
if n in content:
return _sorted_rows(content[n])
if n in sub:
return _sorted_rows(sub[n])
return []
return {
'goals': pick('learning_goals', 'learningGoals', 'pbl_learning_goal', 'goal'),
'project': pick('project', 'pbl_project', 'projects'),
'problem': pick('problem', 'driving_question', 'pbl_problem'),
'missions': pick('missions', 'pbl_mission', 'mission'),
'roles': pick('roles', 'pbl_role', 'role'),
'artifacts': pick('artifacts', 'artifact_defs', 'pbl_artifact_def', 'artifact'),
'evidence_specs': pick('evidence_specs', 'pbl_evidence_spec', 'evidence'),
'rubrics': pick('rubrics', 'pbl_rubric', 'rubric'),
'events': pick('events', 'pbl_event', 'event'),
'scenes': pick('scenes', 'pbl_scene', 'scene'),
'assets': pick('assets', 'pbl_asset', 'asset', 'resources'),
'raw': content,
}
def build_pbl(objs, snapshot_meta):
"""``pbl`` 键learner/learningGoals/problem/drivingQuestion/project 聚合。"""
raw = objs.get('raw') or {}
project = objs['project'][0] if objs['project'] else {}
problem = objs['problem'][0] if objs['problem'] else {}
goals = []
for idx, g in enumerate(objs['goals']):
goals.append({
'goal_id': stable_id(snapshot_meta['blueprint_id'],
snapshot_meta['blueprint_version_no'], 'goal', idx),
'code': _uid(g, 'goal', idx),
'title': _text(g, 'title', 'name', 'goal_title'),
'description': _text(g, 'description', 'desc', 'content'),
'dimension': _text(g, 'dimension', 'category', default='knowledge'),
'weight': _int(g, 'weight', 'weight_pct', default=0),
})
return {
'learner': {
'stage': _text(raw, 'learner_stage', 'stage', default='secondary'),
'group_size': _int(raw, 'group_size', 'team_size', default=4),
'prior_knowledge': _text(raw, 'prior_knowledge', default=''),
},
'learningGoals': goals,
'problem': {
'problem_id': stable_id(snapshot_meta['blueprint_id'],
snapshot_meta['blueprint_version_no'], 'problem', 0),
'statement': _text(problem, 'statement', 'description', 'title'),
'constraints': _sorted_rows(problem.get('constraints'))
if isinstance(problem, dict) else [],
},
'drivingQuestion': _text(problem, 'driving_question', 'drivingQuestion',
default=_text(raw, 'driving_question')),
'project': {
'title': _text(project, 'title', 'name'),
'summary': _text(project, 'summary', 'description'),
'duration_hours': _int(project, 'duration_hours', 'duration', default=0),
'domain': _text(project, 'domain', 'domain_code', default='general'),
},
}
def build_world(objs, snapshot_meta):
"""``world`` 键世界定义M3b 映射 world 表,不改基表结构)。"""
raw = objs.get('raw') or {}
project = objs['project'][0] if objs['project'] else {}
return {
'world_key': stable_id(snapshot_meta['blueprint_id'],
snapshot_meta['blueprint_version_no'], 'world', 0),
'name': _text(project, 'title', 'name', default='PBL World'),
'mode': _text(raw, 'world_mode', 'mode', default='shared'),
'authority': 'server',
'domain': _text(project, 'domain', 'domain_code', default='general'),
'max_members': _int(raw, 'max_members', 'group_size', default=8),
'heartbeat_seconds': _int(raw, 'heartbeat_seconds', default=15),
'runtime_neutral': True,
'renderer_hint': _text(raw, 'renderer', 'renderer_hint', default='three.js'),
}
def build_scenes(objs, snapshot_meta):
"""``scenes`` 键pbl_mission 分组 → 场景列表M3b 映射 scene 表)。"""
bp_id = snapshot_meta['blueprint_id']
ver = snapshot_meta['blueprint_version_no']
scenes = []
missions = objs['missions'] or ([{'title': 'main', 'code': 'main'}]
if not objs['scenes'] else [])
for idx, m in enumerate(missions):
mid = _uid(m, 'mission', idx)
scenes.append({
'scene_key': stable_id(bp_id, ver, 'scene', idx),
'source': 'pbl_mission:%s' % mid,
'name': _text(m, 'title', 'name', default='Mission %d' % (idx + 1)),
'order_no': _int(m, 'order_no', 'order', 'seq', default=idx),
'unlock_condition': _loads(m.get('unlock_condition'), {}) if isinstance(m, dict) else {},
'environment': _text(m, 'environment', 'scene_type', default='default'),
'entity_refs': [],
})
for idx, s in enumerate(objs['scenes'] or []):
scenes.append({
'scene_key': stable_id(bp_id, ver, 'scene', len(missions) + idx),
'source': 'pbl_scene:%s' % _uid(s, 'scene', idx),
'name': _text(s, 'title', 'name', default='Scene %d' % (idx + 1)),
'order_no': _int(s, 'order_no', 'order', default=len(missions) + idx),
'unlock_condition': _loads(s.get('unlock_condition'), {}) if isinstance(s, dict) else {},
'environment': _text(s, 'environment', 'scene_type', default='default'),
'entity_refs': [],
})
scenes.sort(key=lambda x: (x['order_no'], x['scene_key']))
return scenes
def build_entities(objs, snapshot_meta, scenes):
"""``entities`` 键pbl_role + pbl_artifact_def + mission/task → 实体列表。
每个实体带 ``scene_key`` 归属默认落第 1 个场景 M3b 映射 entity
"""
bp_id = snapshot_meta['blueprint_id']
ver = snapshot_meta['blueprint_version_no']
default_scene = scenes[0]['scene_key'] if scenes else stable_id(bp_id, ver, 'scene', 0)
entities = []
seq = 0
for m in objs['missions'] or []:
mid = _uid(m, 'mission', seq)
entities.append({
'entity_key': stable_id(bp_id, ver, 'entity', seq),
'kind': 'mission', 'source': 'pbl_mission:%s' % mid,
'name': _text(m, 'title', 'name', default=mid),
'scene_key': default_scene,
'capability_key': 'pbl.mission.show',
'server_authoritative': True,
'initial_state': {'completed': False,
'order': _int(m, 'order_no', 'order', default=seq)},
})
seq += 1
tasks = _sorted_rows(m.get('tasks')) if isinstance(m, dict) else []
for t in tasks:
tk = _uid(t, 'task', seq)
entities.append({
'entity_key': stable_id(bp_id, ver, 'entity', seq),
'kind': 'task', 'source': 'pbl_task:%s' % tk,
'name': _text(t, 'title', 'name', default=tk),
'scene_key': default_scene,
'capability_key': 'pbl.task.track',
'server_authoritative': True,
'initial_state': {'done': False, 'parent': mid},
})
seq += 1
for r in objs['roles'] or []:
rk = _uid(r, 'role', seq)
entities.append({
'entity_key': stable_id(bp_id, ver, 'entity', seq),
'kind': 'role', 'source': 'pbl_role:%s' % rk,
'name': _text(r, 'title', 'name', default=rk),
'scene_key': default_scene,
'capability_key': 'pbl.role.assign',
'server_authoritative': True,
'initial_state': {'assigned': False,
'permissions': _sorted_rows(r.get('permissions'))
if isinstance(r, dict) else []},
})
seq += 1
for a in objs['artifacts'] or []:
ak = _uid(a, 'artifact', seq)
entities.append({
'entity_key': stable_id(bp_id, ver, 'entity', seq),
'kind': 'artifact_spec', 'source': 'pbl_artifact_def:%s' % ak,
'name': _text(a, 'title', 'name', default=ak),
'scene_key': default_scene,
'capability_key': 'pbl.artifact.submit',
'server_authoritative': True,
'initial_state': {'submitted': False,
'artifact_type': _text(a, 'artifact_type', 'type',
default='document')},
})
seq += 1
entities.sort(key=lambda x: x['entity_key'])
for sc in scenes:
sc['entity_refs'] = sorted(
e['entity_key'] for e in entities if e['scene_key'] == sc['scene_key'])
return entities
def build_events(objs, snapshot_meta, entities):
"""``events`` 键pbl_mission.unlock_condition + pbl_evidence_spec → 事件模型(第 12 章)。
事件结构 = {event_type, source, condition, response[{op, capability}]}
M3b 映射 script_engine 脚本eventsconditionresponsecapability
"""
bp_id = snapshot_meta['blueprint_id']
ver = snapshot_meta['blueprint_version_no']
events = []
seq = 0
by_source = {}
for e in entities:
by_source.setdefault(e['kind'], []).append(e)
for m in by_source.get('mission', []):
cond = m.get('initial_state', {}).get('unlock_condition')
events.append({
'event_key': stable_id(bp_id, ver, 'event', seq),
'event_type': 'mission.completed',
'source': m['source'],
'condition': {'all_of': ['%s.completed == true' % m['entity_key']]},
'response': [{'op': 'emit', 'event': 'mission.completed',
'capability': 'pbl.mission.show'}],
'unlock_condition': _loads(cond, {}) if cond else {},
})
seq += 1
for t in by_source.get('task', []):
events.append({
'event_key': stable_id(bp_id, ver, 'event', seq),
'event_type': 'task.completed',
'source': t['source'],
'condition': {'all_of': ['%s.done == true' % t['entity_key']]},
'response': [{'op': 'emit', 'event': 'task.completed',
'capability': 'pbl.task.track'}],
})
seq += 1
for idx, ev in enumerate(objs['evidence_specs'] or []):
ek = _uid(ev, 'evidence_spec', idx)
events.append({
'event_key': stable_id(bp_id, ver, 'event', seq),
'event_type': 'evidence.required',
'source': 'pbl_evidence_spec:%s' % ek,
'condition': {'min_count': _int(ev, 'min_count', 'count', default=1)},
'response': [{'op': 'collect',
'evidence_type': _text(ev, 'evidence_type', 'type',
default='artifact'),
'capability': 'pbl.evidence.collect'}],
})
seq += 1
for idx, ev in enumerate(objs['events'] or []):
events.append({
'event_key': stable_id(bp_id, ver, 'event', seq),
'event_type': _text(ev, 'event_type', 'type', default='custom'),
'source': 'pbl_event:%s' % _uid(ev, 'event', idx),
'condition': _loads(ev.get('condition'), {}) if isinstance(ev, dict) else {},
'response': _loads(ev.get('response'), []) if isinstance(ev, dict) else [],
})
seq += 1
events.sort(key=lambda x: canonical_json({k: v for k, v in x.items()
if k != 'event_key'},
strip=False, sort_lists=False))
for i, ev in enumerate(events):
ev['event_key'] = stable_id(bp_id, ver, 'event', i)
return events
def build_states(entities):
"""``states`` 键实体初始状态契约entity_key → initial_state 快照)。"""
states = {}
for e in entities:
states[e['entity_key']] = {
'kind': e['kind'],
'initial': e.get('initial_state', {}),
'server_authoritative': bool(e.get('server_authoritative', True)),
}
return {k: states[k] for k in sorted(states.keys())}
def build_capabilities(objs, entities, events, registry_rows):
"""``capabilities`` 键§6.1 能力注册表快照pbl.* 能力清单)。
DB 注册表为准registry_rows 由调用方查出传入并补齐 GD 实际引用到
但注册表缺失的能力 ``registered: false`` M4b fail-closed 裁决告警
"""
used = sorted({e.get('capability_key') for e in entities if e.get('capability_key')}
| {r.get('capability') for ev in events
for r in (ev.get('response') or []) if isinstance(r, dict)
and r.get('capability')})
known = {}
for row in registry_rows or []:
if not isinstance(row, dict):
continue
key = str(row.get('capability_key') or row.get('key') or '')
if not key:
continue
known[key] = {
'capability_key': key,
'category': _text(row, 'category', default='pbl'),
'version_no': _int(row, 'version_no', 'version', default=1),
'permission_required': _text(row, 'permission_required', default=''),
'is_enabled': bool(row.get('is_enabled', True)),
'args_schema_hash': short_hash(
row.get('args_schema_json') or row.get('args_schema') or '', 16),
'registered': True,
'used_by_gd': key in used,
}
for key in used:
if key not in known:
known[key] = {'capability_key': key, 'category': 'pbl', 'version_no': 1,
'permission_required': '', 'is_enabled': True,
'args_schema_hash': '', 'registered': False,
'used_by_gd': True}
return {'schema': 'pbl.capability_list.v1',
'registry_hash': registry_hash(list(known.values())),
'items': [known[k] for k in sorted(known.keys())]}
def build_assessment(objs, snapshot_meta):
"""``assessment`` 键pbl_rubric 只读权重结构34 章M6 消费)。"""
bp_id = snapshot_meta['blueprint_id']
ver = snapshot_meta['blueprint_version_no']
dimensions = []
for idx, r in enumerate(objs['rubrics'] or []):
criteria = []
for cidx, c in enumerate(_sorted_rows(r.get('criteria')) if isinstance(r, dict) else []):
criteria.append({
'criterion_key': stable_id(bp_id, ver, 'criterion', cidx),
'name': _text(c, 'title', 'name', default='c%d' % cidx),
'weight': _int(c, 'weight', 'weight_pct', default=0),
'levels': _sorted_rows(c.get('levels')) if isinstance(c, dict) else [],
})
criteria.sort(key=lambda x: (x['name'], x['criterion_key']))
dimensions.append({
'dimension_key': stable_id(bp_id, ver, 'rubric', idx),
'source': 'pbl_rubric:%s' % _uid(r, 'rubric', idx),
'name': _text(r, 'title', 'name', default='rubric%d' % idx),
'weight': _int(r, 'weight', 'weight_pct', default=0),
'criteria': criteria,
})
dimensions.sort(key=lambda x: x['dimension_key'])
total = sum(d['weight'] for d in dimensions)
return {'schema': 'pbl.assessment.v1', 'readonly': True,
'dimensions': dimensions, 'total_weight': total,
'weight_normalized': (total == 100) if dimensions else True}
def build_assets(objs):
"""``assets`` 键资源引用清单runtime-neutral29.4 不绑定渲染端)。"""
assets = []
for idx, a in enumerate(objs['assets'] or []):
url = _text(a, 'url', 'asset_url', 'src', 'path')
assets.append({
'asset_key': _uid(a, 'asset', idx),
'kind': _text(a, 'kind', 'type', 'asset_type', default='model'),
'url': url,
'url_hash': short_hash(url, 16) if url else '',
'mime': _text(a, 'mime', 'mime_type', default=''),
'runtime_neutral': True,
})
assets.sort(key=lambda x: (x['kind'], x['asset_key']))
return {'schema': 'pbl.assets.v1', 'runtime_neutral': True, 'items': assets}
def build_manifest(ctx, gd_body, capabilities):
"""``manifest`` 键:编译元数据。
**createdAt / durationMs 属易变字段**写进 GD 正文供审计但被
``canonical.VOLATILE_KEYS`` 排除在指纹计算外29.6无时间戳注入
"""
return {
'schema': GD_SCHEMA,
'gdTopKeys': list(GD_TOP_KEYS),
'blueprintId': ctx['blueprint_id'],
'blueprintVersion': ctx['blueprint_version_no'],
'compilerVersion': ctx['compiler_version'],
'rulesetVersion': ctx['ruleset_version'],
'rulesHash': ctx['rules_hash'],
'registryHash': capabilities.get('registry_hash', ''),
'deterministic': True,
'llmUsed': False,
'counts': {
'scenes': len(gd_body['scenes']),
'entities': len(gd_body['entities']),
'events': len(gd_body['events']),
'capabilities': len(capabilities.get('items', [])),
'rubricDimensions': len(gd_body['assessment'].get('dimensions', [])),
'assets': len(gd_body['assets'].get('items', [])),
},
# 易变字段(不参与指纹)
'createdAt': ctx.get('created_at', ''),
'durationMs': ctx.get('duration_ms', 0),
'taskNo': ctx.get('task_no', ''),
}
def fingerprint_of(gd):
"""GD 内容指纹(剔除易变键后的规范化 SHA-256"""
return sha256_fingerprint(gd)
def build_game_definition(snapshot, ctx, registry_rows=None):
"""蓝图版本快照 → 完整 GD10 顶层键)+ 内容指纹。
:param snapshot: 蓝图版本快照dict JSON 来自 pbl_blueprint.get_version
:param ctx: {'blueprint_id','blueprint_version_no','compiler_version',
'ruleset_version','rules_hash','created_at','duration_ms','task_no'}
:param registry_rows: pbl_capability_registry 调用方查出传入本模块不碰 DB
:return: (gd_dict, fingerprint_str)
"""
objs = extract_snapshot_objects(snapshot)
meta = {'blueprint_id': ctx['blueprint_id'],
'blueprint_version_no': ctx['blueprint_version_no']}
scenes = build_scenes(objs, meta)
entities = build_entities(objs, meta, scenes)
events = build_events(objs, meta, entities)
capabilities = build_capabilities(objs, entities, events, registry_rows)
body = {
'pbl': build_pbl(objs, meta),
'world': build_world(objs, meta),
'scenes': scenes,
'entities': entities,
'events': events,
'states': build_states(entities),
'capabilities': capabilities,
'assessment': build_assessment(objs, meta),
'assets': build_assets(objs),
}
gd = {'manifest': build_manifest(ctx, body, capabilities)}
gd.update(body)
missing = [k for k in GD_TOP_KEYS if k not in gd]
if missing:
raise ValueError('GD 缺顶层键: %s' % ','.join(missing))
fp = fingerprint_of(gd)
gd['manifest']['fingerprint'] = fp
return gd, fp