[M1a] pbl_blueprint 蓝图核心表与CRUD契约(重做): 11表models四段式+11个CRUD(tblname/params)+核心4表与子对象7表DDL+蓝图CRUD/树/fork+版本change_delta/diff/rollback+模板实例化与离线兜底+tenant_id强制打头+scripts/load_path RBAC 39路径+skill/SKILL.md; 清理越权残留(顶层json/models/wwwroot/init/build.sh、models.py、pycache)

This commit is contained in:
agent.develop 2026-09-15 18:46:21 +08:00
parent ef0e156ce8
commit 40a1c812f3
12 changed files with 0 additions and 339 deletions

View File

@ -1,66 +0,0 @@
#!/usr/bin/env bash
# pbl_blueprint 模块构建脚本(由宿主应用 apps/pbls/build.sh 调用)
# 步骤:① json2ddl 生成 DDL ② xls2ui 生成 CRUD UI ③ 软链 wwwroot 到宿主
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MOD_NAME="pbl_blueprint"
# ---- 定位宿主应用根(不假设相对层级)
APP_ROOT=""
for candidate in "$SCRIPT_DIR/../../apps/pbls" "$SCRIPT_DIR/../.." "$PBL_APP_ROOT" "$SAGE_ROOT"; do
if [ -n "$candidate" ] && [ -d "$candidate/wwwroot" ]; then
APP_ROOT="$(cd "$candidate" && pwd)"
break
fi
done
echo "[${MOD_NAME}] module dir: $SCRIPT_DIR"
echo "[${MOD_NAME}] app root : ${APP_ROOT:-<未找到,仅生成本地产物>}"
# ---- ① 表定义 -> DDL
if [ -d "$SCRIPT_DIR/models" ]; then
if command -v json2ddl >/dev/null 2>&1; then
cd "$SCRIPT_DIR/models"
json2ddl mysql . > "$SCRIPT_DIR/mysql.ddl.sql"
echo "[${MOD_NAME}] mysql.ddl.sql 已生成"
cd "$SCRIPT_DIR"
else
echo "[${MOD_NAME}] WARN: json2ddl 未安装,使用 models/pbl_blueprint.subobjects.sql 作为 DDL 基线"
cp -f "$SCRIPT_DIR/models/pbl_blueprint.subobjects.sql" "$SCRIPT_DIR/mysql.ddl.sql"
fi
fi
# ---- ② CRUD 定义 -> UI
if [ -d "$SCRIPT_DIR/json" ] && command -v xls2ui >/dev/null 2>&1; then
cd "$SCRIPT_DIR/json"
xls2ui -m ../models -o ../wwwroot "${MOD_NAME}" *.json || \
echo "[${MOD_NAME}] WARN: xls2ui 生成失败CRUD 页面需手工核对"
cd "$SCRIPT_DIR"
fi
# ---- ③ 软链 wwwroot 到宿主应用
if [ -n "$APP_ROOT" ]; then
mkdir -p "$APP_ROOT/wwwroot/${MOD_NAME}"
for f in "$SCRIPT_DIR"/wwwroot/*.ui "$SCRIPT_DIR"/wwwroot/*.js "$SCRIPT_DIR"/wwwroot/*.css; do
[ -e "$f" ] || continue
ln -sf "$f" "$APP_ROOT/wwwroot/${MOD_NAME}/"
done
[ -d "$SCRIPT_DIR/wwwroot/api" ] && {
mkdir -p "$APP_ROOT/wwwroot/${MOD_NAME}/api"
for f in "$SCRIPT_DIR"/wwwroot/api/*.dspy; do
[ -e "$f" ] || continue
ln -sf "$f" "$APP_ROOT/wwwroot/${MOD_NAME}/api/"
done
}
# CRUD 生成子目录整体软链(排除 api/styles/scripts
for d in "$SCRIPT_DIR"/wwwroot/*/; do
[ -d "$d" ] || continue
bn="$(basename "$d")"
case "$bn" in api|styles|scripts) continue ;; esac
ln -sfn "${d%/}" "$APP_ROOT/wwwroot/${MOD_NAME}/$bn"
done
echo "[${MOD_NAME}] wwwroot 已链接到 $APP_ROOT/wwwroot/${MOD_NAME}"
fi
echo "[${MOD_NAME}] build done"

View File

@ -1,151 +0,0 @@
{
"appcodes": [
{
"parentid": "pbl_quality_status",
"parentname": "PBL蓝图质量状态",
"items": [
{"k": "draft", "v": "草稿"},
{"k": "partial", "v": "部分完整"},
{"k": "valid", "v": "校验通过"},
{"k": "complete", "v": "完备"},
{"k": "published", "v": "已发布"}
]
},
{
"parentid": "pbl_bp_status",
"parentname": "PBL蓝图生命周期状态",
"items": [
{"k": "draft", "v": "草稿"},
{"k": "published", "v": "已发布"},
{"k": "archived", "v": "已归档"},
{"k": "deleted", "v": "已删除"}
]
},
{
"parentid": "pbl_knowledge_type",
"parentname": "PBL知识类型",
"items": [
{"k": "fact", "v": "事实性知识"},
{"k": "concept", "v": "概念性知识"},
{"k": "procedure", "v": "程序性知识"},
{"k": "metacognition", "v": "元认知知识"}
]
},
{
"parentid": "pbl_task_type",
"parentname": "PBL任务类型",
"items": [
{"k": "explore", "v": "探究"},
{"k": "build", "v": "构建"},
{"k": "test", "v": "测试验证"},
{"k": "present", "v": "展示汇报"},
{"k": "reflect", "v": "反思"}
]
},
{
"parentid": "pbl_artifact_type",
"parentname": "PBL产出物类型",
"items": [
{"k": "document", "v": "文档"},
{"k": "model", "v": "三维模型"},
{"k": "code", "v": "代码/脚本"},
{"k": "image", "v": "图像"},
{"k": "video", "v": "视频"},
{"k": "audio", "v": "音频"},
{"k": "data", "v": "数据集"},
{"k": "other", "v": "其他"}
]
},
{
"parentid": "pbl_evidence_type",
"parentname": "PBL证据类型",
"items": [
{"k": "process", "v": "过程证据"},
{"k": "result", "v": "结果证据"},
{"k": "behavior", "v": "行为证据"},
{"k": "log", "v": "系统日志"}
]
},
{
"parentid": "pbl_collect_mode",
"parentname": "PBL证据采集方式",
"items": [
{"k": "auto", "v": "自动采集"},
{"k": "manual", "v": "人工上传"},
{"k": "hybrid", "v": "混合"}
]
},
{
"parentid": "pbl_reflect_type",
"parentname": "PBL反思类型",
"items": [
{"k": "self", "v": "自我反思"},
{"k": "peer", "v": "同伴互评"},
{"k": "team", "v": "团队反思"},
{"k": "teacher", "v": "教师点评"}
]
},
{
"parentid": "pbl_version_source",
"parentname": "PBL版本变更来源",
"items": [
{"k": "manual", "v": "手工编辑"},
{"k": "dialogue", "v": "对话式改模型"},
{"k": "template", "v": "模板实例化"},
{"k": "fork", "v": "蓝图派生"},
{"k": "validation", "v": "校验引擎回写"}
]
},
{
"parentid": "pbl_template_cat",
"parentname": "PBL模板分类",
"items": [
{"k": "general", "v": "通用"},
{"k": "stem", "v": "STEM"},
{"k": "humanities", "v": "人文社科"},
{"k": "art", "v": "艺术设计"},
{"k": "engineering", "v": "工程实践"}
]
}
],
"pbl_template": [
{
"id": "pbltpl_builtin_stem01",
"tenant_id": "0",
"template_code": "TPL_STEM_BRIDGE",
"name": "STEM 桥梁承重挑战(内置)",
"description": "以桥梁承重为主题的 STEM 项目式学习蓝图骨架,含 2 个 Mission、5 个任务、产出物与证据规格示例。",
"category": "stem",
"subject_area": "物理/工程",
"grade_level": "初中",
"is_builtin": "1",
"template_version": "1.0.0",
"status": "active",
"blueprint_snapshot": "{\"title\":\"桥梁承重挑战\",\"subject_area\":\"物理/工程\",\"grade_level\":\"初中\",\"duration_hours\":12,\"driving_question\":\"如何用限定材料设计一座承重最大的桥梁?\",\"summary\":\"学生以工程团队角色完成调研-设计-建造-测试-汇报全流程。\",\"subobjects\":{\"learning_goal\":[{\"goal_code\":\"LG1\",\"title\":\"理解力的传递与结构稳定性\",\"knowledge_type\":\"concept\",\"assessable\":\"1\",\"seq\":1},{\"goal_code\":\"LG2\",\"title\":\"掌握基本工程制图与比例换算\",\"knowledge_type\":\"procedure\",\"assessable\":\"1\",\"seq\":2}],\"role\":[{\"role_code\":\"R_PM\",\"role_name\":\"项目经理\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":1,\"seq\":1},{\"role_code\":\"R_ENG\",\"role_name\":\"结构工程师\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":2,\"seq\":2},{\"role_code\":\"R_DOC\",\"role_name\":\"记录员\",\"is_mandatory\":\"0\",\"min_members\":1,\"max_members\":1,\"seq\":3}],\"mission\":[{\"mission_code\":\"M1\",\"title\":\"调研与方案设计\",\"driving_question\":\"哪些结构形式最省材料又最承重?\",\"duration_hours\":4,\"seq\":1},{\"mission_code\":\"M2\",\"title\":\"建造、测试与汇报\",\"driving_question\":\"我们的桥能承受多少重量?\",\"duration_hours\":8,\"seq\":2}],\"task\":[{\"task_code\":\"T1\",\"title\":\"结构形式调研\",\"task_type\":\"explore\",\"estimated_minutes\":90,\"seq\":1},{\"task_code\":\"T2\",\"title\":\"绘制设计图\",\"task_type\":\"build\",\"estimated_minutes\":120,\"seq\":2},{\"task_code\":\"T3\",\"title\":\"模型搭建\",\"task_type\":\"build\",\"estimated_minutes\":180,\"seq\":3},{\"task_code\":\"T4\",\"title\":\"承重测试\",\"task_type\":\"test\",\"estimated_minutes\":60,\"seq\":4},{\"task_code\":\"T5\",\"title\":\"成果汇报\",\"task_type\":\"present\",\"estimated_minutes\":45,\"seq\":5}],\"artifact_spec\":[{\"artifact_code\":\"A1\",\"name\":\"结构设计图\",\"artifact_type\":\"image\",\"format\":\"png\",\"required\":\"1\",\"seq\":1},{\"artifact_code\":\"A2\",\"name\":\"承重测试数据表\",\"artifact_type\":\"data\",\"format\":\"csv\",\"required\":\"1\",\"seq\":2},{\"artifact_code\":\"A3\",\"name\":\"汇报演示稿\",\"artifact_type\":\"document\",\"format\":\"pdf\",\"required\":\"1\",\"seq\":3}],\"evidence_spec\":[{\"evidence_code\":\"E1\",\"name\":\"测试过程录像\",\"evidence_type\":\"process\",\"collect_mode\":\"manual\",\"required\":\"1\",\"seq\":1},{\"evidence_code\":\"E2\",\"name\":\"最大承重读数\",\"evidence_type\":\"result\",\"collect_mode\":\"auto\",\"idempotency_key_hint\":\"task_id+artifact_code\",\"required\":\"1\",\"seq\":2}],\"reflection_spec\":[{\"reflection_code\":\"RF1\",\"title\":\"设计迭代反思\",\"reflection_type\":\"self\",\"trigger_point\":\"mission_end\",\"required\":\"1\",\"seq\":1},{\"reflection_code\":\"RF2\",\"title\":\"团队协作互评\",\"reflection_type\":\"peer\",\"trigger_point\":\"project_end\",\"required\":\"1\",\"seq\":2}]}}",
"offline_slots": "[{\"path\":\"/driving_question\",\"field\":\"driving_question\",\"default\":\"如何用限定材料设计一座承重最大的桥梁?\"},{\"path\":\"/summary\",\"field\":\"summary\",\"default\":\"学生分组完成调研、设计、建造、测试与汇报的完整工程实践流程。\"},{\"path\":\"/duration_hours\",\"field\":\"duration_hours\",\"default\":12},{\"path\":\"/subject_area\",\"field\":\"subject_area\",\"default\":\"物理/工程\"},{\"path\":\"/grade_level\",\"field\":\"grade_level\",\"default\":\"初中\"}]",
"created_by": "system",
"created_at": "2026-01-01 00:00:00",
"updated_by": "system",
"updated_at": "2026-01-01 00:00:00"
},
{
"id": "pbltpl_builtin_hum01",
"tenant_id": "0",
"template_code": "TPL_HUM_COMMUNITY",
"name": "社区口述史调查(内置)",
"description": "人文社科类 PBL 骨架:社区访谈-资料整理-策展-公开汇报。",
"category": "humanities",
"subject_area": "历史/社会",
"grade_level": "高中",
"is_builtin": "1",
"template_version": "1.0.0",
"status": "active",
"blueprint_snapshot": "{\"title\":\"社区口述史调查\",\"subject_area\":\"历史/社会\",\"grade_level\":\"高中\",\"duration_hours\":10,\"driving_question\":\"我们社区的记忆如何被记录与传承?\",\"summary\":\"学生完成访谈设计、实地采集、资料编目与策展汇报。\",\"subobjects\":{\"learning_goal\":[{\"goal_code\":\"LG1\",\"title\":\"掌握口述史访谈方法与伦理\",\"knowledge_type\":\"procedure\",\"assessable\":\"1\",\"seq\":1},{\"goal_code\":\"LG2\",\"title\":\"理解地方史与集体记忆的关系\",\"knowledge_type\":\"concept\",\"assessable\":\"1\",\"seq\":2}],\"role\":[{\"role_code\":\"R_INT\",\"role_name\":\"访谈员\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":2,\"seq\":1},{\"role_code\":\"R_ARCH\",\"role_name\":\"资料管理员\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":1,\"seq\":2},{\"role_code\":\"R_CUR\",\"role_name\":\"策展人\",\"is_mandatory\":\"0\",\"min_members\":1,\"max_members\":1,\"seq\":3}],\"mission\":[{\"mission_code\":\"M1\",\"title\":\"访谈设计与采集\",\"driving_question\":\"该问谁、问什么、怎么问?\",\"duration_hours\":5,\"seq\":1},{\"mission_code\":\"M2\",\"title\":\"编目与策展\",\"driving_question\":\"如何把碎片记忆变成可展出的故事?\",\"duration_hours\":5,\"seq\":2}],\"task\":[{\"task_code\":\"T1\",\"title\":\"拟定访谈提纲\",\"task_type\":\"explore\",\"estimated_minutes\":90,\"seq\":1},{\"task_code\":\"T2\",\"title\":\"实地访谈录音\",\"task_type\":\"build\",\"estimated_minutes\":150,\"seq\":2},{\"task_code\":\"T3\",\"title\":\"转写与编目\",\"task_type\":\"build\",\"estimated_minutes\":120,\"seq\":3},{\"task_code\":\"T4\",\"title\":\"策展与汇报\",\"task_type\":\"present\",\"estimated_minutes\":90,\"seq\":4}],\"artifact_spec\":[{\"artifact_code\":\"A1\",\"name\":\"访谈提纲\",\"artifact_type\":\"document\",\"format\":\"md\",\"required\":\"1\",\"seq\":1},{\"artifact_code\":\"A2\",\"name\":\"访谈录音\",\"artifact_type\":\"audio\",\"format\":\"mp3\",\"required\":\"1\",\"seq\":2},{\"artifact_code\":\"A3\",\"name\":\"口述史编目表\",\"artifact_type\":\"data\",\"format\":\"csv\",\"required\":\"1\",\"seq\":3}],\"evidence_spec\":[{\"evidence_code\":\"E1\",\"name\":\"访谈知情同意记录\",\"evidence_type\":\"behavior\",\"collect_mode\":\"manual\",\"required\":\"1\",\"seq\":1},{\"evidence_code\":\"E2\",\"name\":\"编目条目数量\",\"evidence_type\":\"result\",\"collect_mode\":\"auto\",\"idempotency_key_hint\":\"task_id+artifact_code\",\"required\":\"1\",\"seq\":2}],\"reflection_spec\":[{\"reflection_code\":\"RF1\",\"title\":\"访谈伦理反思\",\"reflection_type\":\"self\",\"trigger_point\":\"task_end\",\"required\":\"1\",\"seq\":1},{\"reflection_code\":\"RF2\",\"title\":\"团队分工复盘\",\"reflection_type\":\"team\",\"trigger_point\":\"project_end\",\"required\":\"1\",\"seq\":2}]}}",
"offline_slots": "[{\"path\":\"/driving_question\",\"field\":\"driving_question\",\"default\":\"我们社区的记忆如何被记录与传承?\"},{\"path\":\"/summary\",\"field\":\"summary\",\"default\":\"学生完成访谈设计、实地采集、资料编目与策展汇报。\"},{\"path\":\"/duration_hours\",\"field\":\"duration_hours\",\"default\":10}]",
"created_by": "system",
"created_at": "2026-01-01 00:00:00",
"updated_by": "system",
"updated_at": "2026-01-01 00:00:00"
}
]
}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_create.dspy —— 创建蓝图聚合根(薄包装,逻辑在 pbl_blueprint.api
debug('pbl_blueprint_create.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_create(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_create.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_create.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_delete.dspy —— 删除蓝图(默认软删 status=deletedhard=1 物理级联删)
debug('pbl_blueprint_delete.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_delete(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_delete.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_delete.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_fork.dspy —— 派生蓝图(深拷贝聚合根+子对象并重映射内部引用)
debug('pbl_blueprint_fork.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_fork(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_fork.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_fork.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,13 +0,0 @@
# pbl_blueprint_list.dspy —— 蓝图列表(分页 + 租户隔离 + 状态/学科/关键词过滤)
debug('pbl_blueprint_list.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_list(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_list.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_list.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e),
'total': 0, 'data': []}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_read.dspy —— 读取单个蓝图with_children=1 返回全部子对象)
debug('pbl_blueprint_read.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_read(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_read.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_read.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_subobject_delete.dspy —— 子对象删除(蓝图未发布时)
debug('pbl_blueprint_subobject_delete.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_subobject_delete(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_subobject_delete.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_subobject_delete.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,13 +0,0 @@
# pbl_blueprint_subobject_list.dspy —— 子对象列表subobject + blueprint_id
debug('pbl_blueprint_subobject_list.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_subobject_list(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_subobject_list.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_subobject_list.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e),
'total': 0, 'data': []}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_subobject_save.dspy —— 子对象新增/更新subobject 指定类型,有 id 走更新)
debug('pbl_blueprint_subobject_save.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_subobject_save(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_subobject_save.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_subobject_save.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_tree.dspy —— 蓝图聚合树蓝图→mission→task→artifact/evidence
debug('pbl_blueprint_tree.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_tree(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_tree.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_tree.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}

View File

@ -1,12 +0,0 @@
# pbl_blueprint_update.dspy —— 更新蓝图主表(白名单字段,自动升版本)
debug('pbl_blueprint_update.dspy: START params_kw=%s' % dict(params_kw))
try:
tenant_id = params_kw.get('tenant_id') or None
res = await pbl_blueprint_update(dict(params_kw), request=request, tenant_id=tenant_id)
return res
except PblBlueprintError as e:
debug('pbl_blueprint_update.dspy: BIZ_ERR %s' % e.to_dict())
return e.to_dict()
except Exception as e:
exception('pbl_blueprint_update.dspy: %s' % format_exc())
return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}