feat: 落地 SVGlide 设计资产组合路由

- 新增 deck recipe/style pack/semantic route registry 与 42 条路由用例,支持未知主题 fail-closed。

- 将 select_style 接入 recipe -> template family -> style_pack -> palette/component/image treatment,并回填 selection metadata 与 style_lock。

- 新增 diversity_gate 并接入 runner/quality_gate,阻断 style drift 和 template/style/layout/component 组合复用。

- 更新 SVG route 文档、schema 与私有索引,并补充 selector/review/preflight/quality/runner 测试。
This commit is contained in:
songtianyi.theo
2026-06-23 21:09:48 +08:00
parent 809d7d1a5b
commit 8007c4e51a
33 changed files with 3113 additions and 89 deletions

View File

@@ -25,6 +25,7 @@ metadata:
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| SVG route 需要主题/模板家族 | 先用 beautiful template family matcher 选择 deck-level family、variants、components、asset strategy | `beautiful_template_matcher.py``beautiful-html-template-families.json``component-registry.json``asset-strategy-registry.json` |
| SVG route 需要从主题提示词选择设计资产 | 先运行 design asset routingrecipe -> template family -> style pack -> palette/component/image treatment并由 diversity gate 防同质化 | `svglide-design-asset-routing.md``svglide_recipe_selector.py``svglide_diversity_gate.py` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
@@ -47,7 +48,7 @@ metadata:
**CRITICAL — SVG route 创建前 MUST 先通过 [`scripts/svg_preflight.py`](scripts/svg_preflight.py) 的 plan/source gate再对本地 HTML/SVG preview 运行 [`scripts/svg_preview_lint.py`](scripts/svg_preview_lint.py),并运行 [`scripts/svglide_semantic_review.py`](scripts/svglide_semantic_review.py) 校验中文、页型、章节、内容厚度和 SVG 可见文本来源。preview 中不得展示 safe-area/debug guide文本溢出、大数字窄框、明显重叠、英文 plan、缺页型或 generator 硬编码文本都会阻断 `slides +create-svg`。live create 后仍需 readback gateHTML preview 不能替代服务端转换后的验证。**
**CRITICAL — SVG route 需要主题、模板、版式或用户只给主题时MUST 先运行 [`scripts/beautiful_template_matcher.py`](scripts/beautiful_template_matcher.py) 或等价逻辑,写入 deck-level `template_family_selection`,并为每页写入 `template_variant`、`component_selection`、`asset_strategy`。模板家族来源是 [`references/beautiful-html-template-families.json`](references/beautiful-html-template-families.json);旧 baseline theme、旧 style preset、旧 visual recipe、旧 beautiful preset 不得作为默认生成入口。需要真实图片的页面必须声明 `image_slots`,并由 `svg_preflight.py` 挡住缺图、图文不一致和 AI/local generated bitmap 冒充真实图。**
**CRITICAL — SVG route 需要主题、模板、版式或用户只给主题时MUST 先 [`references/svglide-design-asset-routing.md`](references/svglide-design-asset-routing.md) 运行 design asset routing`svglide_recipe_selector.py` 写入 `deck_recipe_selection`、`template_family_selection`、`style_pack_selection`、`density_mode_selection`、`component_variant_selection`、`image_treatment_selection` 和 deck-level `style_lock``beautiful_template_matcher.py` 仅作为 template family/component/asset registry 的补充入口。模板家族来源是 [`references/beautiful-html-template-families.json`](references/beautiful-html-template-families.json);旧 baseline theme、旧 style preset、旧 visual recipe、旧 beautiful preset 不得作为默认生成入口。需要真实图片的页面必须声明 `image_slots`,并由 `svg_preflight.py` 挡住缺图、图文不一致和 AI/local generated bitmap 冒充真实图。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
@@ -319,4 +320,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## SVG Route
`slides +create-svg` 只作为命令入口出现在顶层。出现 SVG / SVGlide / `slides +create-svg` 需求时,先读取 [`svglide-route-admission.md`](references/svglide-route-admission.md),命中后再按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载 SVG 私有协议、规划、验证和排障文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅作为兼容索引保留。新建或大幅改写 SVG deck 时,优先使用 [`svglide_project_runner.py`](scripts/svglide_project_runner.py) 管理控制面:`init` 建目录,`source` 归一化 `source/evidence.json` 并写入 source receipt`plan` 由 agent 或外部产物阶段完成并写入对应文件`strategy_review` 先锁定语言、受众、页型、章节、内容厚度和 `visual_identity``confirm_plan` 仅作为兼容/人工审批面保留不是默认必经节点runner 再负责 `assets -> generate_svg -> contract_compile -> prepare -> preview -> preflight -> preview_lint -> aesthetic_review -> chart_verify -> semantic_review -> runtime_review -> visual_distinctness_review -> quality_gate`。本地内容验收使用 `run .lark-slides/plan/<deck-id> --profile preview_only` 默认停在 `quality_gate``assets` 负责资产契约和 token/local-file 审计,`generate_svg``artboard_satori` 模式只产 `04-artboard/raw` raw visual/semantic artifacts`contract_compile` 才注入 SVGlide/Slides 私有协议并写入 `04-svg/page-###.svg`、contract report/manifest 和资产注入摘要,`prepare` 只消费 contract manifest 校验过的新鲜 canonical SVG。`chart_verify` 只在页面声明 required/exact chart contract 时强制,`semantic_review` 负责 `semantic-review.json``text-inventory.json``runtime_review` 负责 renderer/layout 多样性,`visual_distinctness_review` 负责阻断不同主题复用同一套 palette、cover treatment 和 renderer/layout 序列。live create 前必须有新鲜的 `quality_gate.status=passed``dry-run.json``ppe-proof.json`live create 后必须进入 readback stage。XML route 不得读取 SVG 私有清单中的策略正文。
`slides +create-svg` 只作为命令入口出现在顶层。出现 SVG / SVGlide / `slides +create-svg` 需求时,先读取 [`svglide-route-admission.md`](references/svglide-route-admission.md),命中后再按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载 SVG 私有协议、规划、验证和排障文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅作为兼容索引保留。新建或大幅改写 SVG deck 时,优先使用 [`svglide_project_runner.py`](scripts/svglide_project_runner.py) 管理控制面:`init` 建目录,`source` 归一化 `source/evidence.json` 并写入 source receipt`select_style` 执行 recipe/style_pack/palette/template routing`plan` 将 selection receipts 回填到 `slide_plan.json``strategy_review` 先锁定语言、受众、页型、章节、内容厚度和 `visual_identity``confirm_plan` 仅作为兼容/人工审批面保留不是默认必经节点runner 再负责 `assets -> generate_svg -> contract_compile -> prepare -> preview -> preflight -> preview_lint -> aesthetic_review -> chart_verify -> semantic_review -> runtime_review -> visual_distinctness_review -> diversity_gate -> theme_adherence -> quality_gate`。本地内容验收使用 `run .lark-slides/plan/<deck-id> --profile preview_only` 默认停在 `quality_gate``assets` 负责资产契约和 token/local-file 审计,`generate_svg``artboard_satori` 模式只产 `04-artboard/raw` raw visual/semantic artifacts`contract_compile` 才注入 SVGlide/Slides 私有协议并写入 `04-svg/page-###.svg`、contract report/manifest 和资产注入摘要,`prepare` 只消费 contract manifest 校验过的新鲜 canonical SVG。`chart_verify` 只在页面声明 required/exact chart contract 时强制,`semantic_review` 负责 `semantic-review.json``text-inventory.json``runtime_review` 负责 renderer/layout 多样性,`visual_distinctness_review` 负责阻断不同主题复用同一套 palette、cover treatment 和 renderer/layout 序列`diversity_gate` 负责阻断 template/style/layout/component 组合过度复用和 style lock 漂移。live create 前必须有新鲜的 `quality_gate.status=passed``dry-run.json``ppe-proof.json`live create 后必须进入 readback stage。XML route 不得读取 SVG 私有清单中的策略正文。

View File

@@ -9,6 +9,7 @@
"private_strategy_files": [
"skills/lark-slides/references/svglide-svg-private.rules.json",
"skills/lark-slides/references/svglide-design-asset-composition-tdd-plan.md",
"skills/lark-slides/references/svglide-design-asset-routing.md",
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
@@ -39,6 +40,13 @@
"skills/lark-slides/references/svglide-asset-planning.md",
"skills/lark-slides/references/safe-native-v1.profile.json",
"skills/lark-slides/references/svglide-plan.schema.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.schema.json",
"skills/lark-slides/references/svglide-style-pack-registry.json",
"skills/lark-slides/references/svglide-style-pack-registry.schema.json",
"skills/lark-slides/references/svglide-semantic-route-cases.json",
"skills/lark-slides/references/svglide-semantic-route-cases.schema.json",
"skills/lark-slides/references/svglide-diversity-gate.schema.json",
"skills/lark-slides/references/svglide-canvas-spec.schema.json",
"skills/lark-slides/references/svglide-semantic-map.schema.json",
"skills/lark-slides/references/svglide-node-layout-map.schema.json",
@@ -77,6 +85,11 @@
"skills/lark-slides/scripts/artboard_renderer/templates/README.md",
"skills/lark-slides/scripts/beautiful_template_runtime.py",
"skills/lark-slides/scripts/beautiful_template_matcher.py",
"skills/lark-slides/scripts/svglide_recipe_selector.py",
"skills/lark-slides/scripts/svglide_palette_selector.py",
"skills/lark-slides/scripts/svglide_theme_template_selector.py",
"skills/lark-slides/scripts/svglide_selection_review.py",
"skills/lark-slides/scripts/svglide_diversity_gate.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
@@ -112,6 +125,11 @@
"skills/lark-slides/scripts/svglide_artboard_renderer.py",
"skills/lark-slides/scripts/svglide_contract_compile.py",
"skills/lark-slides/scripts/svglide_template_fit_check.py",
"skills/lark-slides/scripts/svglide_recipe_selector.py",
"skills/lark-slides/scripts/svglide_palette_selector.py",
"skills/lark-slides/scripts/svglide_theme_template_selector.py",
"skills/lark-slides/scripts/svglide_selection_review.py",
"skills/lark-slides/scripts/svglide_diversity_gate.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",

View File

@@ -0,0 +1,291 @@
{
"version": "svglide-deck-recipe-registry/v1",
"recipes": [
{
"recipe_id": "internal_business_review",
"display_name": "内部业务复盘",
"keywords": ["内部", "复盘", "经营", "业务", "年度", "季度", "管理层", "指标", "KPI", "看板", "数据", "趋势", "问题", "原因", "行动", "owner", "增长", "渠道", "留存", "转化"],
"intent_tags": ["复盘", "经营分析", "业务汇报"],
"content_shape": ["数据-洞察-决策", "问题-原因-动作", "KPI 看板"],
"audience": ["内部管理层", "业务团队", "经营负责人"],
"metadata": {
"mood": ["professional", "trustworthy"],
"tone": ["analytical", "controlled"],
"best_for": ["内部复盘", "经营分析", "业务汇报"],
"avoid_for": ["儿童教育", "纯文化叙事"],
"density": "medium-high",
"formality": "high"
},
"template_family_candidates": ["blue-professional", "signal", "emerald-editorial", "raw-grid"],
"style_pack_candidates": ["corporate_blue_data", "teal_amber_growth"],
"density_modes": ["data-heavy", "executive-summary"],
"component_slots": ["kpi_strip", "metric_cards", "comparison_matrix", "timeline", "risk_matrix", "action_list"],
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
"avoid_when": ["小吃", "旅行", "儿童故事"]
},
{
"recipe_id": "consumer_ai_product_analysis",
"display_name": "AI 产品与 App 分析",
"keywords": ["AI", "App", "应用", "豆包", "Doubao", "智谱", "MiniMax", "Kimi", "通义", "产品", "竞品", "对比", "能力", "用户场景", "截图", "模型", "智能体"],
"intent_tags": ["产品分析", "竞品分析", "AI 产品"],
"content_shape": ["能力矩阵", "场景拆解", "产品截图证据"],
"audience": ["产品团队", "业务团队", "管理层"],
"metadata": {
"mood": ["technical", "modern"],
"tone": ["comparative", "analytical"],
"best_for": ["AI 产品", "App 竞品", "公司对比"],
"avoid_for": ["传统美食", "儿童课程"],
"density": "medium-high",
"formality": "medium-high"
},
"template_family_candidates": ["blue-professional", "raw-grid", "neo-grid-bold", "signal"],
"style_pack_candidates": ["product_ai_indigo_cyan", "teal_amber_growth"],
"density_modes": ["product-showcase", "data-heavy"],
"component_slots": ["product_screenshot_panel", "comparison_matrix", "metric_cards", "timeline", "positioning_map"],
"image_treatment_candidates": ["real_product_screenshot", "online_evidence_image"],
"avoid_when": ["纯学术论文", "小吃菜单"]
},
{
"recipe_id": "food_culture_guide",
"display_name": "城市小吃与餐饮文化介绍",
"keywords": ["小吃", "美食", "餐饮", "茶餐厅", "早茶", "香港", "粤菜", "点心", "街头", "菜单", "指南", "品类", "旅行", "推荐", "味道", "城市烟火"],
"intent_tags": ["文化介绍", "美食指南", "城市旅行"],
"content_shape": ["品类地图", "图文故事", "推荐路线"],
"audience": ["普通读者", "游客", "文化爱好者"],
"metadata": {
"mood": ["warm", "editorial"],
"tone": ["narrative", "inviting"],
"best_for": ["香港小吃", "城市美食", "餐饮文化"],
"avoid_for": ["经营看板", "风险审计"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["long-table", "playful", "coral", "grove"],
"style_pack_candidates": ["food_culture_warm_editorial", "culture_editorial_jade_magenta"],
"density_modes": ["editorial-guide", "image-led"],
"component_slots": ["image_story", "menu_grid", "place_cards", "timeline", "quote_panel"],
"image_treatment_candidates": ["online_evidence_image", "editorial_photo"],
"avoid_when": ["董事会", "KPI", "融资"]
},
{
"recipe_id": "culture_heritage_intro",
"display_name": "地域文化与语言文化介绍",
"keywords": ["文化", "粤语", "岭南", "香港", "广东", "语言", "历史", "传统", "流行文化", "影视", "音乐", "城市", "非遗"],
"intent_tags": ["文化科普", "地域介绍", "语言文化"],
"content_shape": ["历史脉络", "文化符号", "当代表达"],
"audience": ["普通读者", "学生", "文化爱好者"],
"metadata": {
"mood": ["cultural", "reflective"],
"tone": ["narrative", "magazine"],
"best_for": ["粤语文化", "地域文化", "城市文化"],
"avoid_for": ["经营复盘", "技术架构"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["editorial-tri-tone", "soft-editorial", "biennale-yellow", "stencil-tablet"],
"style_pack_candidates": ["culture_editorial_jade_magenta", "food_culture_warm_editorial"],
"density_modes": ["editorial-story", "culture-brief"],
"component_slots": ["timeline", "quote_panel", "image_story", "comparison_cards", "fact_cards"],
"image_treatment_candidates": ["editorial_photo", "online_evidence_image"],
"avoid_when": ["KPI", "投委会", "事故"]
},
{
"recipe_id": "event_expo_report",
"display_name": "展会与活动报告",
"keywords": ["ChinaJoy", "CJ", "展会", "活动", "游戏展", "发布会", "大会", "现场", "展台", "观众", "厂商", "趋势", "cosplay", "电竞", "互动体验"],
"intent_tags": ["活动介绍", "展会复盘", "行业观察"],
"content_shape": ["现场图景", "参展方地图", "趋势洞察"],
"audience": ["市场团队", "行业读者", "管理层"],
"metadata": {
"mood": ["energetic", "modern"],
"tone": ["editorial", "reporting"],
"best_for": ["ChinaJoy", "展会报告", "活动复盘"],
"avoid_for": ["审计汇报", "学术论文"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["neo-grid-bold", "block-frame", "creative-mode", "studio"],
"style_pack_candidates": ["event_neon_cyan_magenta", "product_ai_indigo_cyan"],
"density_modes": ["media-showcase", "event-report"],
"component_slots": ["hero_photo", "event_map", "trend_cards", "timeline", "quote_panel"],
"image_treatment_candidates": ["online_event_photo_first", "online_evidence_image"],
"avoid_when": ["董事会", "合规", "事故"]
},
{
"recipe_id": "market_investment_analysis",
"display_name": "市场与投资分析",
"keywords": ["市场", "投资", "行业", "机会", "格局", "供应链", "新能源", "投委会", "玩家", "规模", "竞争", "风险", "趋势", "数据趋势", "城市治理", "政策", "公共治理", "商业化"],
"intent_tags": ["市场分析", "投资分析", "行业研究"],
"content_shape": ["市场地图", "玩家分层", "机会风险"],
"audience": ["投资人", "投委会", "管理层"],
"metadata": {
"mood": ["professional", "sharp"],
"tone": ["analytical", "crisp"],
"best_for": ["投资分析", "市场格局", "行业研究"],
"avoid_for": ["美食文化", "儿童培训"],
"density": "medium-high",
"formality": "high"
},
"template_family_candidates": ["signal", "cartesian", "blue-professional", "raw-grid"],
"style_pack_candidates": ["investor_navy_gold", "corporate_blue_data"],
"density_modes": ["data-heavy", "executive-summary"],
"component_slots": ["market_map", "metric_cards", "comparison_matrix", "risk_matrix", "timeline"],
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
"avoid_when": ["小吃", "儿童", "活动海报"]
},
{
"recipe_id": "fundraising_pitch",
"display_name": "融资 BP 与路演",
"keywords": ["融资", "BP", "路演", "投资人", "pitch", "商业模式", "收入", "增长", "市场规模", "traction", "团队", "估值", "轮次"],
"intent_tags": ["融资路演", "商业计划", "投资人材料"],
"content_shape": ["问题-方案", "市场-产品-商业化", "里程碑"],
"audience": ["投资人", "创始人", "董事会"],
"metadata": {
"mood": ["confident", "professional"],
"tone": ["crisp", "polished"],
"best_for": ["融资 BP", "投资人路演", "商业计划"],
"avoid_for": ["文化科普", "小吃介绍"],
"density": "medium-high",
"formality": "high"
},
"template_family_candidates": ["block-frame", "signal", "blue-professional", "neo-grid-bold"],
"style_pack_candidates": ["investor_navy_gold", "teal_amber_growth"],
"density_modes": ["executive-summary", "pitch-story"],
"component_slots": ["market_map", "metric_cards", "traction_chart", "timeline", "team_cards"],
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
"avoid_when": ["小吃", "传统文化"]
},
{
"recipe_id": "training_courseware",
"display_name": "培训课件与课程讲解",
"keywords": ["培训", "课件", "课程", "教学", "医疗", "新人", "onboarding", "步骤", "练习", "案例", "学习", "知识点", "考核"],
"intent_tags": ["培训课件", "课程讲解", "学习材料"],
"content_shape": ["目标-步骤-练习", "案例-知识点-总结"],
"audience": ["学员", "新人", "一线团队"],
"metadata": {
"mood": ["approachable", "practical"],
"tone": ["instructional", "clear"],
"best_for": ["培训课件", "医疗培训", "新人 onboarding"],
"avoid_for": ["融资 BP", "投委会"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["daisy-days", "playful", "scatterbrain", "vellum"],
"style_pack_candidates": ["education_sky_green", "workshop_playful_paper"],
"density_modes": ["lesson", "workshop"],
"component_slots": ["lesson_steps", "checklist", "exercise_cards", "case_cards", "summary_cards"],
"image_treatment_candidates": ["online_evidence_image", "structured_fallback"],
"avoid_when": ["投资人", "董事会", "事故"]
},
{
"recipe_id": "technical_architecture_review",
"display_name": "技术架构与系统方案",
"keywords": ["技术", "架构", "系统", "链路", "模块", "服务", "调用", "依赖", "SLA", "性能", "稳定性", "方案", "设计", "数据流"],
"intent_tags": ["技术方案", "系统架构", "工程说明"],
"content_shape": ["模块关系", "流程链路", "风险与演进"],
"audience": ["研发团队", "技术负责人", "架构委员会"],
"metadata": {
"mood": ["technical", "focused"],
"tone": ["structured", "precise"],
"best_for": ["技术架构", "系统设计", "工程方案"],
"avoid_for": ["美食指南", "文化故事"],
"density": "medium-high",
"formality": "high"
},
"template_family_candidates": ["raw-grid", "cartesian", "signal", "blue-professional"],
"style_pack_candidates": ["architecture_mono_cyan", "corporate_blue_data"],
"density_modes": ["diagram-heavy", "data-heavy"],
"component_slots": ["architecture_diagram", "dependency_map", "sequence_flow", "risk_matrix", "timeline"],
"image_treatment_candidates": ["diagram_first", "chart_first"],
"avoid_when": ["小吃", "儿童", "文化旅游"]
},
{
"recipe_id": "security_risk_diagnostic",
"display_name": "安全风险与事故诊断",
"keywords": ["安全", "风险", "事故", "故障", "合规", "审计", "根因", "影响范围", "整改", "治理", "漏洞", "控制项", "复盘"],
"intent_tags": ["风险诊断", "事故复盘", "安全治理"],
"content_shape": ["风险矩阵", "时间线", "整改动作"],
"audience": ["安全团队", "管理层", "审计团队"],
"metadata": {
"mood": ["serious", "controlled"],
"tone": ["diagnostic", "analytical"],
"best_for": ["安全方案", "事故复盘", "合规审计"],
"avoid_for": ["品牌发布", "美食介绍"],
"density": "medium-high",
"formality": "high"
},
"template_family_candidates": ["cartesian", "signal", "blue-professional", "raw-grid"],
"style_pack_candidates": ["graphite_red_risk", "corporate_blue_data"],
"density_modes": ["risk-diagnostic", "data-heavy"],
"component_slots": ["risk_matrix", "timeline", "control_table", "action_list", "impact_map"],
"image_treatment_candidates": ["diagram_first", "chart_first"],
"avoid_when": ["小吃", "儿童课程", "艺术展"]
},
{
"recipe_id": "research_policy_brief",
"display_name": "研究报告与政策简报",
"keywords": ["研究", "报告", "政策", "白皮书", "学术", "论文", "方法", "样本", "发现", "结论", "证据", "实验", "引用"],
"intent_tags": ["研究报告", "政策简报", "学术汇报"],
"content_shape": ["背景-方法-发现", "证据-结论-建议"],
"audience": ["研究团队", "政策团队", "专业读者"],
"metadata": {
"mood": ["scholarly", "trustworthy"],
"tone": ["structured", "analytical"],
"best_for": ["研究报告", "政策简报", "学术汇报"],
"avoid_for": ["潮流发布", "活动海报"],
"density": "high",
"formality": "high"
},
"template_family_candidates": ["vellum", "cartesian", "signal", "monochrome"],
"style_pack_candidates": ["scholarly_vellum_ink", "corporate_blue_data"],
"density_modes": ["report", "data-heavy"],
"component_slots": ["evidence_table", "method_cards", "findings_grid", "quote_panel", "chart_panel"],
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
"avoid_when": ["小吃", "夜店", "游戏展"]
},
{
"recipe_id": "brand_campaign_launch",
"display_name": "品牌发布与营销方案",
"keywords": ["品牌", "发布", "营销", "campaign", "传播", "主张", "新品", "人群", "渠道", "创意", "海报", "视觉", "故事"],
"intent_tags": ["品牌发布", "营销方案", "传播策略"],
"content_shape": ["人群洞察", "核心主张", "传播节奏"],
"audience": ["市场团队", "品牌团队", "管理层"],
"metadata": {
"mood": ["confident", "modern"],
"tone": ["bold", "editorial"],
"best_for": ["品牌发布", "营销方案", "新品上市"],
"avoid_for": ["安全审计", "学术报告"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["neo-grid-bold", "bold-poster", "coral", "block-frame"],
"style_pack_candidates": ["event_neon_cyan_magenta", "teal_amber_growth"],
"density_modes": ["campaign", "media-showcase"],
"component_slots": ["hero_visual", "audience_cards", "timeline", "channel_map", "metric_cards"],
"image_treatment_candidates": ["online_evidence_image", "editorial_photo"],
"avoid_when": ["董事会", "合规", "事故"]
},
{
"recipe_id": "workshop_playbook",
"display_name": "工作坊与共创手册",
"keywords": ["工作坊", "workshop", "共创", "头脑风暴", "议程", "练习", "分组", "产出", "下一步", "复盘", "协作"],
"intent_tags": ["工作坊", "共创", "行动手册"],
"content_shape": ["议程-练习-产出", "问题-共创-行动"],
"audience": ["参与者", "项目团队", "培训师"],
"metadata": {
"mood": ["approachable", "collaborative"],
"tone": ["facilitative", "clear"],
"best_for": ["工作坊", "共创", "培训演练"],
"avoid_for": ["融资 BP", "审计汇报"],
"density": "medium",
"formality": "medium"
},
"template_family_candidates": ["scatterbrain", "daisy-days", "pin-and-paper", "playful"],
"style_pack_candidates": ["workshop_playful_paper", "education_sky_green"],
"density_modes": ["workshop", "lesson"],
"component_slots": ["agenda", "sticky_notes", "exercise_cards", "action_list", "reflection_cards"],
"image_treatment_candidates": ["structured_fallback", "online_evidence_image"],
"avoid_when": ["投委会", "安全事故"]
}
]
}

View File

@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-deck-recipe-registry.schema.json",
"title": "SVGlide deck recipe registry",
"type": "object",
"required": ["version", "recipes"],
"properties": {
"version": {"const": "svglide-deck-recipe-registry/v1"},
"recipes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"recipe_id",
"display_name",
"keywords",
"intent_tags",
"content_shape",
"audience",
"metadata",
"template_family_candidates",
"style_pack_candidates",
"density_modes",
"component_slots",
"image_treatment_candidates",
"avoid_when"
],
"additionalProperties": true
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,90 @@
# SVGlide Design Asset Routing
This reference defines the executable SVG-route design asset layer.
## Goal
SVGlide does not map a prompt directly to one fixed template. It routes design in four layers:
```text
prompt -> deck recipe -> template family -> style pack -> page variants
```
The template family controls structure. The style pack controls palette, typography, background system, chart colors, image treatment, decoration policy, and component bias. This prevents `34 template families` from becoming only `34 looks`.
## Executable Stages
`select_style` runs these selectors in order:
1. `scripts/svglide_recipe_selector.py`
- Input: prompt/instruction/evidence.
- Output: `02-plan/selection-metadata.json`.
- Also copied to `02-plan/recipe-routing-receipt.json` and `receipts/recipe_selection.json`.
2. `scripts/svglide_palette_selector.py`
- If user-provided colors or real brand palette exist, they win.
- Otherwise, a passed style pack supplies `project_palette` from `style_pack_registry`.
- Output: `02-plan/palette-selection.json`.
3. `scripts/svglide_theme_template_selector.py`
- Selects concrete template/theme candidates.
- Output: `02-plan/theme-template-selection.json`.
`plan` then applies all receipts back into `02-plan/slide_plan.json`.
## Required Plan Fields
SVG route plans with design asset selection must carry:
- `deck_recipe_selection`
- `template_family_selection`
- `style_pack_selection`
- `density_mode_selection`
- `component_variant_selection`
- `image_treatment_selection`
- `style_lock`
- `selection_metadata_receipt`
- `recipe_routing_receipt`
- `palette_selection_receipt`
- `selection_receipt`
`style_lock.deck_level` must be `true`. Page-level layout and component variants may vary, but page-level `style_pack_id` or `canvas_spec.palette_id` must not drift away from the deck-level lock.
## Diversity Gate
`scripts/svglide_diversity_gate.py` writes `06-check/diversity-gate.json`.
It blocks:
- missing or non deck-level `style_lock`
- failed L4/fail-closed recipe selection
- slide-level style pack drift
- slide-level palette drift
- high recent reuse of the same `template_id + style_pack_id + layout_variant + component_variant`
For selection decks, `quality_gate` requires:
- `palette-review`
- `theme-template-selection-review`
- `plan-bundle-review`
- `diversity-gate`
## Registries
- `references/svglide-deck-recipe-registry.json`
- `references/svglide-style-pack-registry.json`
- `references/svglide-semantic-route-cases.json`
- `references/beautiful-html-template-families.json`
- `references/component-registry.json`
- `references/asset-strategy-registry.json`
## Validation Commands
```bash
python3 skills/lark-slides/scripts/svglide_recipe_selector_test.py
python3 skills/lark-slides/scripts/svglide_palette_selector_test.py
python3 skills/lark-slides/scripts/svglide_selection_review_test.py
python3 skills/lark-slides/scripts/svglide_diversity_gate_test.py
python3 skills/lark-slides/scripts/svglide_quality_gate_test.py
python3 skills/lark-slides/scripts/svglide_project_runner_test.py
```

View File

@@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-diversity-gate.schema.json",
"title": "SVGlide diversity gate receipt",
"type": "object",
"required": ["schema_version", "stage", "status", "action", "inputs", "signature", "summary", "issues"],
"properties": {
"schema_version": {"const": "svglide-diversity-gate/v1"},
"stage": {"const": "diversity_gate"},
"status": {"enum": ["passed", "failed"]},
"action": {"enum": ["continue_pipeline", "repair_and_rerun"]},
"inputs": {
"type": "object",
"required": ["slide_plan", "history_limit"],
"additionalProperties": true
},
"signature": {
"type": "object",
"required": ["template_family_id", "style_pack_id", "palette_id", "combos", "combo_keys"],
"additionalProperties": true
},
"comparisons": {
"type": "array",
"items": {"type": "object", "additionalProperties": true}
},
"summary": {
"type": "object",
"required": ["error_count", "comparison_count", "combo_count"],
"additionalProperties": true
},
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["code", "message"],
"additionalProperties": true
}
}
},
"additionalProperties": true
}

View File

@@ -44,6 +44,105 @@
},
"additionalProperties": true
},
"deck_recipe_selection": {
"type": "object",
"required": ["recipe_id", "match_level", "confidence", "candidate_recipe_ids", "selection_reason"],
"properties": {
"recipe_id": {"type": "string", "minLength": 1},
"match_level": {"enum": ["L1", "L2", "L3", "L4"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"candidate_recipe_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"missing_signals": {"type": "array", "items": {"type": "string"}},
"selection_reason": {"type": "string", "minLength": 1}
},
"additionalProperties": true
},
"style_pack_selection": {
"type": "object",
"required": [
"selected_style_pack_id",
"palette_id",
"typography_id",
"background_system_id",
"chart_palette_id",
"image_treatment_id",
"decoration_policy_id",
"component_variant_bias"
],
"properties": {
"selected_style_pack_id": {"type": "string", "minLength": 1},
"candidate_style_pack_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"palette_id": {"type": "string", "minLength": 1},
"typography_id": {"type": "string", "minLength": 1},
"background_system_id": {"type": "string", "minLength": 1},
"chart_palette_id": {"type": "string", "minLength": 1},
"image_treatment_id": {"type": "string", "minLength": 1},
"decoration_policy_id": {"type": "string", "minLength": 1},
"component_variant_bias": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"selection_reason": {"type": "string", "minLength": 1}
},
"additionalProperties": true
},
"density_mode_selection": {
"type": "object",
"required": ["selected_density_mode", "candidate_density_modes", "selection_reason"],
"properties": {
"selected_density_mode": {"type": "string", "minLength": 1},
"candidate_density_modes": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"selection_reason": {"type": "string", "minLength": 1}
},
"additionalProperties": true
},
"component_variant_selection": {
"type": "object",
"required": ["selected_component_variants", "candidate_component_variants", "selection_reason"],
"properties": {
"selected_component_variants": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"candidate_component_variants": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"selection_reason": {"type": "string", "minLength": 1}
},
"additionalProperties": true
},
"image_treatment_selection": {
"type": "object",
"required": ["selected_image_treatment_id", "candidate_image_treatment_ids", "selection_reason"],
"properties": {
"selected_image_treatment_id": {"type": "string", "minLength": 1},
"candidate_image_treatment_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"selection_reason": {"type": "string", "minLength": 1}
},
"additionalProperties": true
},
"style_lock": {
"type": "object",
"required": [
"deck_level",
"template_family_id",
"style_pack_id",
"palette_id",
"typography_id",
"background_system_id",
"chart_palette_id",
"image_treatment_id",
"decoration_policy_id",
"component_variant_bias"
],
"properties": {
"deck_level": {"const": true},
"template_family_id": {"type": "string", "minLength": 1},
"style_pack_id": {"type": "string", "minLength": 1},
"palette_id": {"type": "string", "minLength": 1},
"typography_id": {"type": "string", "minLength": 1},
"background_system_id": {"type": "string", "minLength": 1},
"chart_palette_id": {"type": "string", "minLength": 1},
"image_treatment_id": {"type": "string", "minLength": 1},
"decoration_policy_id": {"type": "string", "minLength": 1},
"component_variant_bias": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}
},
"additionalProperties": true
},
"selection_metadata_receipt": {"type": "string", "minLength": 1},
"recipe_routing_receipt": {"type": "string", "minLength": 1},
"palette_selection_receipt": {"type": "string", "minLength": 1},
"selection_receipt": {"type": "string", "minLength": 1},
"project_palette": {

View File

@@ -0,0 +1,11 @@
{
"version": "svglide-semantic-route-cases/v1",
"golden_cases": "skills/lark-slides/scripts/fixtures/svglide_recipe_matching/cases_42.json",
"out_of_sample_cases": "skills/lark-slides/scripts/fixtures/svglide_recipe_matching/out_of_sample.json",
"policy": {
"case_count_is_regression_surface": true,
"case_id_hardcoding_allowed": false,
"prompt_substring_hardcoding_allowed": false,
"l4_policy": "fail_closed"
}
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-semantic-route-cases.schema.json",
"title": "SVGlide semantic route cases",
"type": "object",
"required": ["version", "golden_cases", "out_of_sample_cases"],
"properties": {
"version": {"const": "svglide-semantic-route-cases/v1"},
"golden_cases": {"type": "string", "minLength": 1},
"out_of_sample_cases": {"type": "string", "minLength": 1}
},
"additionalProperties": true
}

View File

@@ -0,0 +1,233 @@
{
"version": "svglide-style-pack-registry/v1",
"style_packs": [
{
"style_pack_id": "corporate_blue_data",
"display_name": "Corporate Blue Data",
"palette_id": "corporate_blue",
"typography_id": "system_sans_executive",
"background_system_id": "clean_panel_grid",
"chart_palette_id": "business_data_blue",
"image_treatment_id": "chart_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["kpi_strip", "metric_cards", "comparison_matrix", "risk_matrix"],
"metadata": {
"mood": ["professional", "trustworthy", "focused"],
"tone": ["analytical", "controlled", "clean"],
"best_for": ["内部复盘", "经营分析", "管理层汇报", "数据看板"],
"avoid_for": ["儿童教育", "潮流活动", "美食文化"],
"density": "medium-high",
"formality": "high"
}
},
{
"style_pack_id": "product_ai_indigo_cyan",
"display_name": "Product AI Indigo Cyan",
"palette_id": "indigo_cyan",
"typography_id": "tech_editorial",
"background_system_id": "clean_panel_grid",
"chart_palette_id": "ai_product_chart",
"image_treatment_id": "real_product_screenshot",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["product_screenshot_panel", "comparison_matrix", "metric_cards", "timeline"],
"metadata": {
"mood": ["technical", "confident", "modern"],
"tone": ["analytical", "comparative", "precise"],
"best_for": ["AI 产品", "App 分析", "公司对比", "竞品分析"],
"avoid_for": ["传统文化", "小吃菜单", "儿童课程"],
"density": "medium-high",
"formality": "medium-high"
}
},
{
"style_pack_id": "teal_amber_growth",
"display_name": "Teal Amber Growth",
"palette_id": "teal_amber",
"typography_id": "system_sans_product",
"background_system_id": "soft_metric_panels",
"chart_palette_id": "growth_teal_amber",
"image_treatment_id": "online_evidence_image",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["growth_funnel", "metric_cards", "roadmap", "case_cards"],
"metadata": {
"mood": ["optimistic", "focused", "professional"],
"tone": ["pragmatic", "analytical", "clear"],
"best_for": ["增长方案", "产品策略", "业务方案", "路线图"],
"avoid_for": ["事故复盘", "安全风险", "学术论文"],
"density": "medium",
"formality": "medium-high"
}
},
{
"style_pack_id": "graphite_red_risk",
"display_name": "Graphite Red Risk",
"palette_id": "graphite_red",
"typography_id": "system_sans_control",
"background_system_id": "risk_panel_grid",
"chart_palette_id": "risk_alert_chart",
"image_treatment_id": "diagram_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["risk_matrix", "timeline", "control_table", "action_list"],
"metadata": {
"mood": ["serious", "trustworthy", "controlled"],
"tone": ["diagnostic", "analytical", "structured"],
"best_for": ["风险诊断", "安全方案", "事故复盘", "合规审计"],
"avoid_for": ["品牌发布", "美食介绍", "节庆活动"],
"density": "medium-high",
"formality": "high"
}
},
{
"style_pack_id": "food_culture_warm_editorial",
"display_name": "Food Culture Warm Editorial",
"palette_id": "warm_coral_jade",
"typography_id": "editorial_sans_cjk",
"background_system_id": "editorial_photo_panels",
"chart_palette_id": "culture_soft_chart",
"image_treatment_id": "online_evidence_image",
"decoration_policy_id": "source_owned_motif_only",
"component_variant_bias": ["image_story", "menu_grid", "place_cards", "timeline"],
"metadata": {
"mood": ["warm", "editorial", "approachable"],
"tone": ["narrative", "cultural", "inviting"],
"best_for": ["小吃", "餐饮文化", "城市美食", "旅行介绍"],
"avoid_for": ["管理层经营复盘", "安全审计", "技术架构"],
"density": "medium",
"formality": "medium"
}
},
{
"style_pack_id": "culture_editorial_jade_magenta",
"display_name": "Culture Editorial Jade Magenta",
"palette_id": "jade_magenta_ink",
"typography_id": "editorial_sans_cjk",
"background_system_id": "image_caption_grid",
"chart_palette_id": "culture_soft_chart",
"image_treatment_id": "editorial_photo",
"decoration_policy_id": "source_owned_motif_only",
"component_variant_bias": ["quote_panel", "timeline", "image_story", "comparison_cards"],
"metadata": {
"mood": ["cultural", "editorial", "reflective"],
"tone": ["narrative", "magazine", "clear"],
"best_for": ["地域文化", "语言文化", "城市介绍", "文化科普"],
"avoid_for": ["高密度经营看板", "融资 BP", "事故复盘"],
"density": "medium",
"formality": "medium"
}
},
{
"style_pack_id": "investor_navy_gold",
"display_name": "Investor Navy Gold",
"palette_id": "navy_gold",
"typography_id": "system_sans_executive",
"background_system_id": "executive_panels",
"chart_palette_id": "investor_chart",
"image_treatment_id": "chart_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["market_map", "metric_cards", "traction_chart", "risk_matrix"],
"metadata": {
"mood": ["confident", "professional", "sharp"],
"tone": ["crisp", "analytical", "polished"],
"best_for": ["融资 BP", "投资分析", "市场规模", "商业模式"],
"avoid_for": ["儿童培训", "文化活动", "小吃介绍"],
"density": "medium-high",
"formality": "high"
}
},
{
"style_pack_id": "education_sky_green",
"display_name": "Education Sky Green",
"palette_id": "sky_green",
"typography_id": "system_sans_learning",
"background_system_id": "lesson_step_panels",
"chart_palette_id": "learning_chart",
"image_treatment_id": "online_evidence_image",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["lesson_steps", "checklist", "exercise_cards", "summary_cards"],
"metadata": {
"mood": ["approachable", "calm", "practical"],
"tone": ["instructional", "clear", "structured"],
"best_for": ["培训课件", "课程介绍", "新人 onboarding", "医疗培训"],
"avoid_for": ["融资路演", "事故复盘", "潮流发布"],
"density": "medium",
"formality": "medium"
}
},
{
"style_pack_id": "event_neon_cyan_magenta",
"display_name": "Event Neon Cyan Magenta",
"palette_id": "neon_cyan_magenta",
"typography_id": "display_sans_event",
"background_system_id": "media_showcase_grid",
"chart_palette_id": "event_signal_chart",
"image_treatment_id": "online_event_photo_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["hero_photo", "event_map", "trend_cards", "timeline"],
"metadata": {
"mood": ["energetic", "confident", "modern"],
"tone": ["editorial", "bold", "reporting"],
"best_for": ["展会", "ChinaJoy", "活动复盘", "发布会"],
"avoid_for": ["董事会经营复盘", "合规审计", "学术政策简报"],
"density": "medium",
"formality": "medium"
}
},
{
"style_pack_id": "scholarly_vellum_ink",
"display_name": "Scholarly Vellum Ink",
"palette_id": "vellum_ink_blue",
"typography_id": "system_sans_report",
"background_system_id": "paper_report_grid",
"chart_palette_id": "policy_chart",
"image_treatment_id": "chart_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["evidence_table", "method_cards", "findings_grid", "quote_panel"],
"metadata": {
"mood": ["scholarly", "serious", "trustworthy"],
"tone": ["structured", "analytical", "clear"],
"best_for": ["研究报告", "政策简报", "学术汇报", "白皮书"],
"avoid_for": ["品牌发布", "夜生活活动", "美食菜单"],
"density": "high",
"formality": "high"
}
},
{
"style_pack_id": "workshop_playful_paper",
"display_name": "Workshop Playful Paper",
"palette_id": "paper_blue_orange",
"typography_id": "system_sans_workshop",
"background_system_id": "workshop_board_grid",
"chart_palette_id": "workshop_chart",
"image_treatment_id": "structured_fallback",
"decoration_policy_id": "source_owned_motif_only",
"component_variant_bias": ["agenda", "sticky_notes", "exercise_cards", "action_list"],
"metadata": {
"mood": ["approachable", "warm", "collaborative"],
"tone": ["instructional", "facilitative", "clear"],
"best_for": ["工作坊", "头脑风暴", "共创", "培训演练"],
"avoid_for": ["董事会看板", "安全审计", "融资 BP"],
"density": "medium",
"formality": "medium"
}
},
{
"style_pack_id": "architecture_mono_cyan",
"display_name": "Architecture Mono Cyan",
"palette_id": "mono_cyan",
"typography_id": "system_sans_technical",
"background_system_id": "diagram_grid",
"chart_palette_id": "technical_chart",
"image_treatment_id": "diagram_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["architecture_diagram", "dependency_map", "sequence_flow", "risk_matrix"],
"metadata": {
"mood": ["technical", "focused", "precise"],
"tone": ["structured", "analytical", "controlled"],
"best_for": ["技术架构", "系统设计", "链路说明", "工程方案"],
"avoid_for": ["美食文化", "活动海报", "儿童课程"],
"density": "medium-high",
"formality": "high"
}
}
]
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://larksuite.local/svglide-style-pack-registry.schema.json",
"title": "SVGlide style pack registry",
"type": "object",
"required": ["version", "style_packs"],
"properties": {
"version": {"const": "svglide-style-pack-registry/v1"},
"style_packs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"style_pack_id",
"display_name",
"palette_id",
"typography_id",
"background_system_id",
"chart_palette_id",
"image_treatment_id",
"decoration_policy_id",
"component_variant_bias",
"metadata"
],
"additionalProperties": true
}
}
},
"additionalProperties": false
}

View File

@@ -8,6 +8,7 @@
],
"private_strategy_files": [
"skills/lark-slides/references/svglide-design-asset-composition-tdd-plan.md",
"skills/lark-slides/references/svglide-design-asset-routing.md",
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
@@ -38,6 +39,13 @@
"skills/lark-slides/references/svglide-asset-planning.md",
"skills/lark-slides/references/safe-native-v1.profile.json",
"skills/lark-slides/references/svglide-plan.schema.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.schema.json",
"skills/lark-slides/references/svglide-style-pack-registry.json",
"skills/lark-slides/references/svglide-style-pack-registry.schema.json",
"skills/lark-slides/references/svglide-semantic-route-cases.json",
"skills/lark-slides/references/svglide-semantic-route-cases.schema.json",
"skills/lark-slides/references/svglide-diversity-gate.schema.json",
"skills/lark-slides/references/svglide-canvas-spec.schema.json",
"skills/lark-slides/references/svglide-semantic-map.schema.json",
"skills/lark-slides/references/svglide-node-layout-map.schema.json",
@@ -76,6 +84,11 @@
"skills/lark-slides/scripts/artboard_renderer/templates/README.md",
"skills/lark-slides/scripts/beautiful_template_runtime.py",
"skills/lark-slides/scripts/beautiful_template_matcher.py",
"skills/lark-slides/scripts/svglide_recipe_selector.py",
"skills/lark-slides/scripts/svglide_palette_selector.py",
"skills/lark-slides/scripts/svglide_theme_template_selector.py",
"skills/lark-slides/scripts/svglide_selection_review.py",
"skills/lark-slides/scripts/svglide_diversity_gate.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
@@ -108,6 +121,11 @@
"skills/lark-slides/scripts/svglide_artboard_renderer.py",
"skills/lark-slides/scripts/svglide_contract_compile.py",
"skills/lark-slides/scripts/svglide_template_fit_check.py",
"skills/lark-slides/scripts/svglide_recipe_selector.py",
"skills/lark-slides/scripts/svglide_palette_selector.py",
"skills/lark-slides/scripts/svglide_theme_template_selector.py",
"skills/lark-slides/scripts/svglide_selection_review.py",
"skills/lark-slides/scripts/svglide_diversity_gate.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",
@@ -134,6 +152,7 @@
"skills/lark-slides/SKILL.md",
"skills/lark-slides/references/svglide-route-admission.md",
"skills/lark-slides/references/svglide-design-asset-composition-tdd-plan.md",
"skills/lark-slides/references/svglide-design-asset-routing.md",
"skills/lark-slides/references/svglide-ppt-master-migration.matrix.md",
"skills/lark-slides/references/svglide-workflow.spec.md",
"skills/lark-slides/references/svglide-artifacts.spec.md",
@@ -152,6 +171,13 @@
"skills/lark-slides/references/svglide-ppt-master-asset-map.schema.json",
"skills/lark-slides/references/svglide-template-admission.schema.json",
"skills/lark-slides/references/svglide-canvas-spec.schema.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.json",
"skills/lark-slides/references/svglide-deck-recipe-registry.schema.json",
"skills/lark-slides/references/svglide-style-pack-registry.json",
"skills/lark-slides/references/svglide-style-pack-registry.schema.json",
"skills/lark-slides/references/svglide-semantic-route-cases.json",
"skills/lark-slides/references/svglide-semantic-route-cases.schema.json",
"skills/lark-slides/references/svglide-diversity-gate.schema.json",
"skills/lark-slides/references/svglide-semantic-map.schema.json",
"skills/lark-slides/references/svglide-node-layout-map.schema.json",
"skills/lark-slides/references/svglide-artboard-receipt.schema.json",
@@ -184,6 +210,11 @@
"skills/lark-slides/scripts/artboard_renderer/templates/README.md",
"skills/lark-slides/scripts/beautiful_template_runtime.py",
"skills/lark-slides/scripts/beautiful_template_matcher.py",
"skills/lark-slides/scripts/svglide_recipe_selector.py",
"skills/lark-slides/scripts/svglide_palette_selector.py",
"skills/lark-slides/scripts/svglide_theme_template_selector.py",
"skills/lark-slides/scripts/svglide_selection_review.py",
"skills/lark-slides/scripts/svglide_diversity_gate.py",
"skills/lark-slides/scripts/svglide_project_runner.py",
"skills/lark-slides/scripts/svglide_strategy_review.py",
"skills/lark-slides/scripts/svglide_prepare.py",

View File

@@ -10,6 +10,8 @@ import re
from pathlib import Path
from typing import Any
import svglide_recipe_selector
SCRIPT_DIR = Path(__file__).resolve().parent
LARK_SLIDES_DIR = SCRIPT_DIR.parent
@@ -354,7 +356,20 @@ def match_templates(query: str, limit: int = 3, page_count: int | None = None, r
def plan_with_template_family(query: str, page_count: int = 10) -> dict[str, Any]:
result = match_templates(query, limit=3, page_count=page_count)
design_selection = svglide_recipe_selector.select_design_assets(query)
preferred_template_id = None
if design_selection.get("status") == "passed":
preferred_template_id = (
design_selection.get("template_family_selection", {}).get("selected_template_id")
if isinstance(design_selection.get("template_family_selection"), dict)
else None
)
selected_match = result["matches"][0]
if preferred_template_id:
selected_match = next(
(match for match in result["matches"] if match.get("template_id") == preferred_template_id),
selected_match,
)
selected = selected_match["template_id"]
variants = selected_match["recommended_variants"]
if not variants:
@@ -373,7 +388,7 @@ def plan_with_template_family(query: str, page_count: int = 10) -> dict[str, Any
"asset_strategy": choose_asset_strategy(blocks[-1], data_available=False),
}
)
return {
plan = {
"version": "beautiful-template-plan/v1",
"target_slide_count": page_count,
"template_family_selection": {
@@ -390,6 +405,20 @@ def plan_with_template_family(query: str, page_count: int = 10) -> dict[str, Any
},
"slides": slides,
}
if design_selection.get("status") == "passed":
for key in [
"deck_recipe_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
plan[key] = design_selection[key]
plan["selection_metadata"] = design_selection
plan["style_lock"]["template_family_id"] = selected
plan["template_family_selection"]["recipe_selected_template_id"] = design_selection["template_family_selection"]["selected_template_id"]
return plan
def main() -> int:

View File

@@ -55,21 +55,23 @@ class BeautifulTemplateMatcherTest(unittest.TestCase):
self.assertEqual(registry["version"], "beautiful-html-template-families/v1")
self.assertEqual(registry["source"]["template_count"], 34)
self.assertEqual(len(registry["families"]), 34)
self.assertEqual(registry["source"]["absorbed_family_count"], 15)
absorption_dir = REFERENCES_DIR / "absorptions/beautiful-html-templates"
self.assertEqual(registry["source"]["absorbed_family_count"], len(list(absorption_dir.glob("*.json"))))
def test_extractor_preserves_inventory_and_absorption_provenance(self) -> None:
registry = beautiful_template_asset_extractor.extract_registry()
families = {family["template_id"]: family for family in registry["families"]}
blue = families["blue-professional"]
absorbed = families["soft-editorial"]
self.assertEqual(blue["status"], "absorbed")
self.assertEqual(blue["claim_level"], "svglide_absorbed")
self.assertTrue(blue["source"]["inventory_item_ids"])
self.assertIn("skills/lark-slides/references/absorptions/beautiful-html-templates/blue-professional.executive-dashboard.json", blue["source"]["absorption_records"])
self.assertIn("beautiful-html-templates.template.blue-professional.design.md", blue["source"]["source_item_ids"])
self.assertTrue(blue["source"]["absorption_provenance"][0]["sha256"])
self.assertIn("template.executive-dashboard", blue["svglide_mapping"]["svglide_asset_ids"])
self.assertIn("beautiful-html-templates.template.blue-professional.design.md", blue["svglide_mapping"]["source_item_ids"])
self.assertEqual(blue["status"], "source_inventoried")
self.assertEqual(blue["claim_level"], "source_inventory_only")
self.assertEqual(absorbed["status"], "absorbed")
self.assertEqual(absorbed["claim_level"], "svglide_absorbed")
self.assertIn("skills/lark-slides/references/absorptions/beautiful-html-templates/soft-editorial.soft-editorial-feature.json", absorbed["source"]["absorption_records"])
self.assertTrue(absorbed["source"]["absorption_provenance"][0]["sha256"])
self.assertTrue(absorbed["svglide_mapping"]["svglide_asset_ids"])
def test_family_registry_extracts_design_assets_not_only_html(self) -> None:
family = beautiful_template_matcher.load_family("blue-professional")
@@ -159,6 +161,17 @@ class BeautifulTemplateMatcherTest(unittest.TestCase):
self.assertIn("metric_dashboard", variants)
self.assertIn("problem_analysis", variants)
self.assertIn("action_plan", variants)
for key in [
"deck_recipe_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
self.assertIn(key, result)
self.assertEqual(result["template_family_selection"]["selected_template_id"], result["style_lock"]["template_family_id"])
self.assertEqual(result["style_lock"]["style_pack_id"], result["style_pack_selection"]["selected_style_pack_id"])
def test_semantic_blocks_select_expected_components(self) -> None:
blocks = [

View File

@@ -0,0 +1,299 @@
{
"version": "svglide-recipe-matching-cases/v1",
"cases": [
{
"case_id": "G01",
"prompt": "内部业务复盘,管理层阅读,包含指标、问题、原因和后续行动",
"expected_primary_type": "经营 / 业务 / 数据分析与复盘",
"expected_recipe_ids": ["internal_business_review"],
"min_match_level": "L1"
},
{
"case_id": "G02",
"prompt": "季度经营 review董事会阅读正式克制需要 KPI dashboard 和 owner action",
"expected_primary_type": "经营 / 业务 / 数据分析与复盘",
"expected_recipe_ids": ["internal_business_review"],
"min_match_level": "L1"
},
{
"case_id": "G03",
"prompt": "渠道健康度复盘,展示指标、瓶颈排序、转化和下季度行动",
"expected_primary_type": "经营 / 业务 / 数据分析与复盘",
"expected_recipe_ids": ["internal_business_review"],
"min_match_level": "L1"
},
{
"case_id": "G04",
"prompt": "用户增长业务汇报,包含留存、转化、问题诊断和路线图",
"expected_primary_type": "经营 / 业务 / 数据分析与复盘",
"expected_recipe_ids": ["internal_business_review"],
"min_match_level": "L1"
},
{
"case_id": "G05",
"prompt": "年度业务总结,给经营负责人看,重点讲 KPI、趋势和下一步动作",
"expected_primary_type": "经营 / 业务 / 数据分析与复盘",
"expected_recipe_ids": ["internal_business_review"],
"min_match_level": "L1"
},
{
"case_id": "G06",
"prompt": "豆包 App 竞品分析,关注产品能力、用户场景和真实产品截图",
"expected_primary_type": "AI / 产品 / App 分析",
"expected_recipe_ids": ["consumer_ai_product_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G07",
"prompt": "智谱和 MiniMax 对比,讲公司定位、模型能力、应用场景和产品截图",
"expected_primary_type": "AI / 产品 / App 分析",
"expected_recipe_ids": ["consumer_ai_product_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G08",
"prompt": "AI 智能体产品竞品分析,比较功能矩阵、场景和商业化方向",
"expected_primary_type": "AI / 产品 / App 分析",
"expected_recipe_ids": ["consumer_ai_product_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G09",
"prompt": "Kimi、通义和豆包 App 对比,展示能力矩阵和真实界面证据",
"expected_primary_type": "AI / 产品 / App 分析",
"expected_recipe_ids": ["consumer_ai_product_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G10",
"prompt": "生成式 AI 应用市场分析,重点讲产品定位、用户场景和竞争差异",
"expected_primary_type": "AI / 产品 / App 分析",
"expected_recipe_ids": ["consumer_ai_product_analysis"],
"min_match_level": "L2"
},
{
"case_id": "G11",
"prompt": "香港小吃介绍,讲茶餐厅、点心、街头美食和旅行推荐",
"expected_primary_type": "文化 / 美食 / 城市旅行",
"expected_recipe_ids": ["food_culture_guide"],
"min_match_level": "L1"
},
{
"case_id": "G12",
"prompt": "广州早茶与粤式点心指南,包含菜单、品类和城市烟火",
"expected_primary_type": "文化 / 美食 / 城市旅行",
"expected_recipe_ids": ["food_culture_guide"],
"min_match_level": "L1"
},
{
"case_id": "G13",
"prompt": "成都街头小吃旅行攻略,温暖图文风格,需要真实美食图片",
"expected_primary_type": "文化 / 美食 / 城市旅行",
"expected_recipe_ids": ["food_culture_guide"],
"min_match_level": "L1"
},
{
"case_id": "G14",
"prompt": "城市夜市美食推荐,包含品类地图、餐饮文化和路线建议",
"expected_primary_type": "文化 / 美食 / 城市旅行",
"expected_recipe_ids": ["food_culture_guide"],
"min_match_level": "L1"
},
{
"case_id": "G15",
"prompt": "粤语文化介绍,讲语言历史、影视音乐和当代表达",
"expected_primary_type": "文化 / 语言 / 地域介绍",
"expected_recipe_ids": ["culture_heritage_intro"],
"min_match_level": "L1"
},
{
"case_id": "G16",
"prompt": "岭南文化科普,包含历史、传统、非遗和城市符号",
"expected_primary_type": "文化 / 语言 / 地域介绍",
"expected_recipe_ids": ["culture_heritage_intro"],
"min_match_level": "L1"
},
{
"case_id": "G17",
"prompt": "香港流行文化介绍,讲影视、音乐、城市记忆和时代变化",
"expected_primary_type": "文化 / 语言 / 地域介绍",
"expected_recipe_ids": ["culture_heritage_intro"],
"min_match_level": "L1"
},
{
"case_id": "G18",
"prompt": "ChinaJoy 展会报告,讲现场、展台、观众、厂商和行业趋势",
"expected_primary_type": "活动 / 展会 / 行业观察",
"expected_recipe_ids": ["event_expo_report"],
"min_match_level": "L1"
},
{
"case_id": "G19",
"prompt": "游戏展活动复盘,包含参展方地图、观众画像和互动体验",
"expected_primary_type": "活动 / 展会 / 行业观察",
"expected_recipe_ids": ["event_expo_report"],
"min_match_level": "L1"
},
{
"case_id": "G20",
"prompt": "品牌发布会现场总结,讲现场图片、发布节奏和传播趋势",
"expected_primary_type": "活动 / 展会 / 行业观察",
"expected_recipe_ids": ["event_expo_report", "brand_campaign_launch"],
"min_match_level": "L2"
},
{
"case_id": "G21",
"prompt": "新能源供应链投资分析,给投委会看,包含市场格局、玩家分层、机会和风险",
"expected_primary_type": "市场 / 投资 / 行业研究",
"expected_recipe_ids": ["market_investment_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G22",
"prompt": "城市治理年度复盘,包含数据趋势、问题诊断、政策动作和风险",
"expected_primary_type": "市场 / 投资 / 行业研究",
"expected_recipe_ids": ["internal_business_review", "market_investment_analysis"],
"min_match_level": "L2"
},
{
"case_id": "G23",
"prompt": "消费行业市场分析,讲市场规模、玩家分层、竞争格局和商业化",
"expected_primary_type": "市场 / 投资 / 行业研究",
"expected_recipe_ids": ["market_investment_analysis"],
"min_match_level": "L1"
},
{
"case_id": "G24",
"prompt": "AI 教育产品融资 BP面向投资人讲市场规模、商业模式、收入增长和团队",
"expected_primary_type": "融资 / BP / 商业计划",
"expected_recipe_ids": ["fundraising_pitch"],
"min_match_level": "L1"
},
{
"case_id": "G25",
"prompt": "SaaS 公司融资路演,包含 traction、unit economics、团队和下一轮计划",
"expected_primary_type": "融资 / BP / 商业计划",
"expected_recipe_ids": ["fundraising_pitch"],
"min_match_level": "L1"
},
{
"case_id": "G26",
"prompt": "创业项目商业计划书,讲问题、方案、市场规模、商业模式和融资用途",
"expected_primary_type": "融资 / BP / 商业计划",
"expected_recipe_ids": ["fundraising_pitch"],
"min_match_level": "L1"
},
{
"case_id": "G27",
"prompt": "医疗培训课件,面向一线团队,讲知识点、案例、操作步骤和考核",
"expected_primary_type": "培训 / 课程 / 学习材料",
"expected_recipe_ids": ["training_courseware"],
"min_match_level": "L1"
},
{
"case_id": "G28",
"prompt": "新人 onboarding 培训材料,包含议程、步骤、练习和总结",
"expected_primary_type": "培训 / 课程 / 学习材料",
"expected_recipe_ids": ["training_courseware"],
"min_match_level": "L1"
},
{
"case_id": "G29",
"prompt": "销售团队课程课件,讲客户案例、练习、考核和行动清单",
"expected_primary_type": "培训 / 课程 / 学习材料",
"expected_recipe_ids": ["training_courseware"],
"min_match_level": "L1"
},
{
"case_id": "G30",
"prompt": "SVGlide 技术架构方案,讲系统链路、模块关系、服务调用和风险演进",
"expected_primary_type": "技术 / 架构 / 系统方案",
"expected_recipe_ids": ["technical_architecture_review"],
"min_match_level": "L1"
},
{
"case_id": "G31",
"prompt": "推荐系统架构设计说明,包含数据流、依赖、性能和稳定性方案",
"expected_primary_type": "技术 / 架构 / 系统方案",
"expected_recipe_ids": ["technical_architecture_review"],
"min_match_level": "L1"
},
{
"case_id": "G32",
"prompt": "工程链路优化方案讲服务链路、调用依赖、SLA 和演进计划",
"expected_primary_type": "技术 / 架构 / 系统方案",
"expected_recipe_ids": ["technical_architecture_review"],
"min_match_level": "L1"
},
{
"case_id": "G33",
"prompt": "安全风险诊断,包含风险矩阵、漏洞、控制项、整改动作和治理计划",
"expected_primary_type": "安全 / 风险 / 事故诊断",
"expected_recipe_ids": ["security_risk_diagnostic"],
"min_match_level": "L1"
},
{
"case_id": "G34",
"prompt": "线上故障事故复盘讲影响范围、时间线、根因、owner action 和整改",
"expected_primary_type": "安全 / 风险 / 事故诊断",
"expected_recipe_ids": ["security_risk_diagnostic"],
"min_match_level": "L1"
},
{
"case_id": "G35",
"prompt": "合规审计汇报,给管理层看,包含控制项、风险和 remediation 计划",
"expected_primary_type": "安全 / 风险 / 事故诊断",
"expected_recipe_ids": ["security_risk_diagnostic"],
"min_match_level": "L1"
},
{
"case_id": "G36",
"prompt": "研究报告简报,包含背景、方法、样本、发现、结论和引用",
"expected_primary_type": "研究 / 政策 / 白皮书",
"expected_recipe_ids": ["research_policy_brief"],
"min_match_level": "L1"
},
{
"case_id": "G37",
"prompt": "政策白皮书汇报,严肃专业,包含证据、结论和建议",
"expected_primary_type": "研究 / 政策 / 白皮书",
"expected_recipe_ids": ["research_policy_brief"],
"min_match_level": "L1"
},
{
"case_id": "G38",
"prompt": "学术论文 poster包含研究方法、实验结果、发现和 affiliation",
"expected_primary_type": "研究 / 政策 / 白皮书",
"expected_recipe_ids": ["research_policy_brief"],
"min_match_level": "L1"
},
{
"case_id": "G39",
"prompt": "新品品牌发布方案,讲人群洞察、核心主张、传播渠道和创意视觉",
"expected_primary_type": "品牌 / 营销 / 发布",
"expected_recipe_ids": ["brand_campaign_launch"],
"min_match_level": "L1"
},
{
"case_id": "G40",
"prompt": "营销 campaign 方案,讲品牌故事、目标人群、传播节奏和效果指标",
"expected_primary_type": "品牌 / 营销 / 发布",
"expected_recipe_ids": ["brand_campaign_launch"],
"min_match_level": "L1"
},
{
"case_id": "G41",
"prompt": "团队共创工作坊手册,包含议程、练习、分组产出和下一步行动",
"expected_primary_type": "工作坊 / 共创 / 行动手册",
"expected_recipe_ids": ["workshop_playbook"],
"min_match_level": "L1"
},
{
"case_id": "G42",
"prompt": "头脑风暴 workshop 总结,讲问题、共创过程、想法分组和行动清单",
"expected_primary_type": "工作坊 / 共创 / 行动手册",
"expected_recipe_ids": ["workshop_playbook"],
"min_match_level": "L1"
}
]
}

View File

@@ -0,0 +1,55 @@
{
"version": "svglide-recipe-matching-out-of-sample/v1",
"cases": [
{
"case_id": "O01",
"prompt": "豆包 App 产品体验报告,讲核心功能、用户旅程和截图证据",
"max_match_level": "L2"
},
{
"case_id": "O02",
"prompt": "智谱与 MiniMax 公司介绍,既要产品对比,也要行业定位",
"max_match_level": "L2"
},
{
"case_id": "O03",
"prompt": "香港小吃文化介绍,重点讲茶餐厅、鱼蛋、点心和真实图片",
"max_match_level": "L2"
},
{
"case_id": "O04",
"prompt": "粤语文化介绍,讲语言、影视歌曲和香港城市记忆",
"max_match_level": "L2"
},
{
"case_id": "O05",
"prompt": "AI 教育产品融资 BP讲市场规模、商业模式和投资亮点",
"max_match_level": "L2"
},
{
"case_id": "O06",
"prompt": "新能源供应链投资分析,讲市场格局、风险和机会",
"max_match_level": "L2"
},
{
"case_id": "O07",
"prompt": "城市治理年度复盘,讲数据趋势、政策动作和后续治理",
"max_match_level": "L2"
},
{
"case_id": "O08",
"prompt": "医疗培训课件,讲操作步骤、案例和考核",
"max_match_level": "L2"
},
{
"case_id": "O09",
"prompt": "ChinaJoy 趋势观察,包含现场图片、参展厂商和游戏行业方向",
"max_match_level": "L2"
},
{
"case_id": "O10",
"prompt": "一份关于未来生活方式的灵感合集,只说要高级感,没有受众和内容结构",
"max_match_level": "L4"
}
]
}

View File

@@ -93,6 +93,15 @@ ART_DIRECTION_REQUIRED_FIELDS = {
"svg_native_moments",
}
BUSINESS_CLAIM_SOURCE_TYPES = {"prompt_provided", "user_provided", "attachment", "readback", "derived", "assumption", "pending_confirmation"}
DESIGN_ASSET_SELECTION_REQUIRED_FIELDS = [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]
SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"}
RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"}
@@ -1968,6 +1977,95 @@ def validate_template_family_policy(plan: dict[str, Any]) -> list[dict[str, Any]
return issues
def validate_design_asset_selection_contract(plan: dict[str, Any]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for field in DESIGN_ASSET_SELECTION_REQUIRED_FIELDS:
if not isinstance(plan.get(field), dict):
issues.append(
plan_issue(
"error",
f"plan_missing_{field}",
f"SVGlide SVG plans must include deck-level {field}",
None,
"Run recipe selection before plan/SVG generation and carry the selection metadata into slide_plan.json.",
)
)
if issues:
return issues
recipe = nested_dict(plan.get("deck_recipe_selection"))
if recipe.get("match_level") == "L4":
issues.append(
plan_issue(
"error",
"plan_recipe_selection_l4",
"L4 recipe selection must fail closed before SVG generation",
None,
"Ask for more content signals or create a reviewed new_recipe_proposal; do not continue with baseline fallback.",
)
)
for field in ["recipe_id", "match_level", "confidence", "signals"]:
if recipe.get(field) in (None, "", []):
issues.append(
plan_issue(
"error",
f"plan_recipe_selection_missing_{field}",
f"deck_recipe_selection must include {field}",
)
)
style_pack = nested_dict(plan.get("style_pack_selection"))
image_treatment = nested_dict(plan.get("image_treatment_selection"))
style_lock_value = nested_dict(plan.get("style_lock"))
if style_lock_value.get("deck_level") is not True:
issues.append(
plan_issue(
"error",
"plan_style_lock_not_deck_level",
"style_lock must be deck_level=true",
None,
"Lock style_pack, palette, typography, image treatment, and decoration policy at deck level.",
)
)
if style_lock_value.get("style_pack_id") != style_pack.get("selected_style_pack_id"):
issues.append(
plan_issue(
"error",
"plan_style_lock_style_pack_mismatch",
"style_lock.style_pack_id must match style_pack_selection.selected_style_pack_id",
)
)
if style_lock_value.get("image_treatment_id") != image_treatment.get("selected_image_treatment_id"):
issues.append(
plan_issue(
"error",
"plan_style_lock_image_treatment_mismatch",
"style_lock.image_treatment_id must match image_treatment_selection.selected_image_treatment_id",
)
)
if style_lock_value.get("decoration_policy_id") in {"random_decorations", "decorative_noise"}:
issues.append(
plan_issue(
"error",
"plan_disallowed_decoration_policy",
"random decorative lines or geometric noise are not allowed",
None,
"Use minimal_grid_only or source-owned motifs with explicit semantic ownership.",
)
)
if normalize_name(textify(plan.get("fallback_policy"))) == "auto":
issues.append(
plan_issue(
"error",
"plan_baseline_fallback_policy_auto",
"fallback_policy=auto is not allowed for design asset composition plans",
None,
"Low-confidence routing must fail closed instead of continuing with a baseline theme.",
)
)
return issues
def slide_visual_plan(slide: dict[str, Any]) -> dict[str, Any]:
visual_plan = slide.get("visual_plan")
if isinstance(visual_plan, dict):
@@ -2678,6 +2776,7 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>") -> dict[str, Any]:
)
)
issues.extend(validate_template_family_policy(plan))
issues.extend(validate_design_asset_selection_contract(plan))
deck_preset_id = deck_style_preset_id(plan)
deck_style_system = style_system(plan)
asset_contract_lookup = asset_contracts_by_id(plan)

View File

@@ -46,6 +46,13 @@ def with_contract(svg: str) -> str:
def style_plan_fields(template_id: str = "raw-grid") -> dict[str, object]:
return {
"deck_recipe_selection": {
"recipe_id": "technical_architecture_review",
"match_level": "L1",
"confidence": 0.88,
"signals": {"keywords": ["技术", "架构"]},
"missing_signals": [],
},
"template_family_selection": {
"enabled": True,
"source": "beautiful-html-template-families",
@@ -53,6 +60,45 @@ def style_plan_fields(template_id: str = "raw-grid") -> dict[str, object]:
"candidate_template_ids": [template_id, "blue-professional", "signal"],
"selection_reason": f"{template_id} fits technical training pages that need dense but readable visual structure",
},
"style_pack_selection": {
"selected_style_pack_id": "architecture_mono_cyan",
"candidate_style_pack_ids": ["architecture_mono_cyan", "corporate_blue_data"],
"selection_reason": "technical architecture requires diagram-first, controlled mono/cyan system",
"palette_id": "mono_cyan",
"typography_id": "system_sans_technical",
"background_system_id": "diagram_grid",
"chart_palette_id": "technical_chart",
"image_treatment_id": "diagram_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["architecture_diagram", "dependency_map"],
},
"density_mode_selection": {
"selected_density_mode": "diagram-heavy",
"candidate_density_modes": ["diagram-heavy", "data-heavy"],
"selection_reason": "architecture decks need diagram-heavy density",
},
"component_variant_selection": {
"selected_component_variants": ["architecture_diagram", "dependency_map"],
"candidate_component_variants": ["architecture_diagram", "dependency_map", "risk_matrix"],
"selection_reason": "selected from recipe component slots",
},
"image_treatment_selection": {
"selected_image_treatment_id": "diagram_first",
"candidate_image_treatment_ids": ["diagram_first", "chart_first"],
"selection_reason": "architecture plan should use diagrams before photos",
},
"style_lock": {
"template_family_id": template_id,
"style_pack_id": "architecture_mono_cyan",
"palette_id": "mono_cyan",
"typography_id": "system_sans_technical",
"background_system_id": "diagram_grid",
"chart_palette_id": "technical_chart",
"image_treatment_id": "diagram_first",
"decoration_policy_id": "minimal_grid_only",
"component_variant_bias": ["architecture_diagram", "dependency_map"],
"deck_level": True,
},
"loaded_rule_set": sorted(svg_preflight.SVG_PRIVATE_REQUIRED_RULE_FILES),
"plan_path": ".lark-slides/plan/test/slide_plan.json",
"quality_gates": {
@@ -831,8 +877,50 @@ class SvgPreflightTest(unittest.TestCase):
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("plan_missing_loaded_rule_set", codes)
self.assertIn("plan_missing_art_direction", codes)
self.assertIn("plan_quality_gate_missing_no_debug_guides", codes)
self.assertIn("plan_quality_gate_missing_no_xml_like_pages", codes)
def test_lint_plan_requires_design_asset_selection_contract(self) -> None:
fields = style_plan_fields()
fields.pop("style_pack_selection")
fields.pop("style_lock")
plan = {
"output_mode": "svglide-svg",
"page_count": 1,
**fields,
"slides": [
{
"page": 1,
"renderer_id": "route_story",
"density": "medium",
"title": "Route",
**recipe_fields("path_flow", ["path", "annotation"]),
}
],
}
result = svg_preflight.lint_plan(plan)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("plan_missing_style_pack_selection", codes)
self.assertIn("plan_missing_style_lock", codes)
def test_lint_plan_rejects_random_decoration_policy_in_style_lock(self) -> None:
fields = style_plan_fields()
fields["style_lock"]["decoration_policy_id"] = "random_decorations" # type: ignore[index]
plan = {
"output_mode": "svglide-svg",
"page_count": 1,
**fields,
"slides": [
{
"page": 1,
"renderer_id": "route_story",
"density": "medium",
"title": "Route",
**recipe_fields("path_flow", ["path", "annotation"]),
}
],
}
result = svg_preflight.lint_plan(plan)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("plan_disallowed_decoration_policy", codes)
def test_lint_plan_route_only_svg_still_requires_svg_gates(self) -> None:
fields = style_plan_fields()
@@ -1182,72 +1270,49 @@ class SvgPreflightTest(unittest.TestCase):
svg_path = tmp / "page-001.svg"
plan_path = tmp / "slide_plan.json"
svg_path.write_text(VALID_SVG, encoding="utf-8")
loaded_rules_json = json.dumps(sorted(svg_preflight.SVG_PRIVATE_REQUIRED_RULE_FILES), indent=20)
plan_path.write_text(
"""
{
"output_mode": "svglide-svg",
"page_count": 1,
"template_family_selection": {
"enabled": true,
"source": "beautiful-html-template-families",
"selected_template_id": "raw-grid",
"candidate_template_ids": ["raw-grid", "blue-professional", "signal"],
"selection_reason": "raw-grid fits technical training pages that need dense but readable visual structure"
},
"loaded_rule_set": __LOADED_RULE_SET__,
"plan_path": ".lark-slides/plan/test/slide_plan.json",
"quality_gates": {
"no_text_overflow": true,
"no_debug_guides": true,
"no_xml_like_pages": true
},
"art_direction": {
"cover_treatment": "hero route cover",
"section_divider_treatment": "not applicable for this one-page test",
"closing_treatment": "not applicable for this one-page test",
"deck_motif": "dense grid panels",
"svg_native_moments": ["route path", "hero image", "annotation geometry"]
},
"svg_files": [{"page": 1, "path": "page-001.svg"}],
"slides": [{
"page": 1,
"renderer_id": "route_story",
"layout_family": "flow",
"density": "medium",
"visual_recipe": "path_flow",
"visual_intent": "show a rising product route",
"visual_focal_point": "curved route line",
"visual_signature": "curved route path with explicit annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"required_primitives": ["path", "annotation"],
"svg_primitives": ["path", "annotation"],
"xml_like_risk": "would become cards plus arrows in XML",
"content_density_contract": "flow >= 4 stages",
"asset_contract": {
"source_type": "procedural",
"license": "original generated asset",
"local_path": "@./assets/hero.jpg",
"usage_page": 1,
"generated_by": "unit test"
},
"risk_flags": [],
"source_policy": "Use prompt-provided content only.",
"template_variant": "path_flow",
"semantic_blocks": [
{"block_id": "title", "type": "title", "content": "Unit test title"},
{"block_id": "message", "type": "finding", "content": "Use path_flow"}
],
"component_selection": [
{"component_id": "title_block", "binds": ["title"]},
{"component_id": "finding_callout", "binds": ["message"]}
],
"asset_strategy": {"strategy_id": "structured_fallback", "expected_asset_count": 0}
}]
}
""".replace("__LOADED_RULE_SET__", loaded_rules_json),
encoding="utf-8",
)
plan = {
"output_mode": "svglide-svg",
"page_count": 1,
"svg_files": [{"page": 1, "path": "page-001.svg"}],
**style_plan_fields(),
"slides": [
{
"page": 1,
"renderer_id": "route_story",
"layout_family": "flow",
"density": "medium",
"visual_recipe": "path_flow",
"visual_intent": "show a rising product route",
"visual_focal_point": "curved route line",
"visual_signature": "curved route path with explicit annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"required_primitives": ["path", "annotation"],
"svg_primitives": ["path", "annotation"],
"xml_like_risk": "would become cards plus arrows in XML",
"content_density_contract": "flow >= 4 stages",
"asset_contract": {
"source_type": "procedural",
"license": "original generated asset",
"local_path": "@./assets/hero.jpg",
"usage_page": 1,
"generated_by": "unit test",
},
"risk_flags": [],
"source_policy": "Use prompt-provided content only.",
"template_variant": "path_flow",
"semantic_blocks": [
{"block_id": "title", "type": "title", "content": "Unit test title"},
{"block_id": "message", "type": "finding", "content": "Use path_flow"},
],
"component_selection": [
{"component_id": "title_block", "binds": ["title"]},
{"component_id": "finding_callout", "binds": ["message"]},
],
"asset_strategy": {"strategy_id": "structured_fallback", "expected_asset_count": 0},
}
],
}
plan_path.write_text(json.dumps(plan), encoding="utf-8")
result = svg_preflight.lint_files([str(svg_path)], str(plan_path))
self.assertEqual(result["summary"]["error_count"], 0)

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PLAN_PATH = Path("02-plan/slide_plan.json")
DESIGN_SELECTION_PATH = Path("02-plan/selection-metadata.json")
CHECK_DIR = Path("06-check")
OUTPUT_PATH = CHECK_DIR / "diversity-gate.json"
PASS_ACTION = "continue_pipeline"
FAIL_ACTION = "repair_and_rerun"
DEFAULT_HISTORY_LIMIT = 8
COMBO_REUSE_FAIL = 0.75
class DiversityGateError(Exception):
pass
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise DiversityGateError(f"missing required file: {path}") from error
except json.JSONDecodeError as error:
raise DiversityGateError(f"invalid JSON in {path}: {error}") from error
if not isinstance(payload, dict):
raise DiversityGateError(f"invalid JSON in {path}: expected object")
return payload
def read_json_optional(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return read_json_object(path)
except (OSError, DiversityGateError):
return {}
def write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def relpath(path: Path, base: Path) -> str:
try:
return path.resolve().relative_to(base.resolve()).as_posix()
except ValueError:
return path.as_posix()
def issue(code: str, message: str, *, path: str | None = None, compared_project: str | None = None) -> dict[str, str]:
payload = {"code": code, "message": message}
if path:
payload["path"] = path
if compared_project:
payload["compared_project"] = compared_project
return payload
def list_value(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return [value]
return []
def style_lock_from(plan: dict[str, Any], design_selection: dict[str, Any]) -> dict[str, Any]:
lock = plan.get("style_lock")
if isinstance(lock, dict):
return lock
lock = design_selection.get("style_lock")
return lock if isinstance(lock, dict) else {}
def template_family_id(plan: dict[str, Any], lock: dict[str, Any]) -> str:
raw = lock.get("template_family_id")
if isinstance(raw, str) and raw:
return raw
selection = plan.get("template_family_selection")
if isinstance(selection, dict):
raw = selection.get("selected_template_id")
if isinstance(raw, str) and raw:
return raw
return "unknown-template-family"
def style_pack_id(lock: dict[str, Any]) -> str:
raw = lock.get("style_pack_id")
return raw if isinstance(raw, str) and raw else "unknown-style-pack"
def component_variant(slide: dict[str, Any], plan: dict[str, Any]) -> str:
raw = slide.get("component_variant") or slide.get("template_variant")
if isinstance(raw, str) and raw:
return raw
selection = slide.get("component_selection")
if isinstance(selection, list):
ids = [str(item.get("component_id")) for item in selection if isinstance(item, dict) and item.get("component_id")]
if ids:
return "+".join(ids[:3])
deck_selection = plan.get("component_variant_selection")
if isinstance(deck_selection, dict):
values = list_value(deck_selection.get("selected_component_variants"))
if values:
return "+".join(values[:3])
return "unknown-component"
def layout_variant(slide: dict[str, Any]) -> str:
for key in ("layout_variant", "layout_family", "renderer_id", "page_type"):
raw = slide.get(key)
if isinstance(raw, str) and raw:
return raw
return "unknown-layout"
def slide_combo(slide: dict[str, Any], plan: dict[str, Any], lock: dict[str, Any]) -> dict[str, str]:
spec = slide.get("canvas_spec") if isinstance(slide.get("canvas_spec"), dict) else {}
template_id = spec.get("template_id") or template_family_id(plan, lock)
return {
"page": str(slide.get("page") or ""),
"template_id": str(template_id),
"template_family_id": template_family_id(plan, lock),
"style_pack_id": style_pack_id(lock),
"palette_id": str(lock.get("palette_id") or plan.get("project_palette", {}).get("palette_id") or ""),
"layout_variant": layout_variant(slide),
"component_variant": component_variant(slide, plan),
}
def combo_key(combo: dict[str, str]) -> tuple[str, str, str, str]:
return (
combo.get("template_id", ""),
combo.get("style_pack_id", ""),
combo.get("layout_variant", ""),
combo.get("component_variant", ""),
)
def plan_signature(plan: dict[str, Any], design_selection: dict[str, Any]) -> dict[str, Any]:
lock = style_lock_from(plan, design_selection)
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
combos = [slide_combo(slide, plan, lock) for slide in slides if isinstance(slide, dict)]
return {
"style_lock": lock,
"template_family_id": template_family_id(plan, lock),
"style_pack_id": style_pack_id(lock),
"palette_id": str(lock.get("palette_id") or plan.get("project_palette", {}).get("palette_id") or ""),
"image_treatment_id": str(lock.get("image_treatment_id") or ""),
"decoration_policy_id": str(lock.get("decoration_policy_id") or ""),
"combos": combos,
"combo_keys": ["|".join(combo_key(combo)) for combo in combos],
}
def validate_current_plan(plan: dict[str, Any], design_selection: dict[str, Any], signature: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
lock = signature.get("style_lock") if isinstance(signature.get("style_lock"), dict) else {}
if not lock:
issues.append(issue("style_lock_missing", "SVGlide selection decks must carry a deck-level style_lock", path="style_lock"))
elif lock.get("deck_level") is not True:
issues.append(issue("style_lock_not_deck_level", "style_lock.deck_level must be true", path="style_lock.deck_level"))
if design_selection and design_selection.get("status") == "failed":
issues.append(issue("recipe_selection_failed", "design asset selection failed closed before diversity gate", path=DESIGN_SELECTION_PATH.as_posix()))
if not signature.get("combos"):
issues.append(issue("diversity_combo_missing", "slides must produce at least one diversity combo", path="slides"))
plan_palette = plan.get("project_palette") if isinstance(plan.get("project_palette"), dict) else {}
lock_palette = lock.get("palette_id")
if isinstance(lock_palette, str) and lock_palette and isinstance(plan_palette.get("style_pack_id"), str):
if plan_palette.get("style_pack_id") != lock.get("style_pack_id"):
issues.append(issue("style_pack_palette_mismatch", "project_palette.style_pack_id must match style_lock.style_pack_id", path="project_palette.style_pack_id"))
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
for index, slide in enumerate(slides):
if not isinstance(slide, dict):
continue
raw_style = slide.get("style_pack_id")
if isinstance(raw_style, str) and raw_style and raw_style != signature.get("style_pack_id"):
issues.append(issue("slide_style_pack_drift", "slide-level style_pack_id must not drift from deck style_lock", path=f"slides[{index}].style_pack_id"))
spec = slide.get("canvas_spec") if isinstance(slide.get("canvas_spec"), dict) else {}
raw_palette = spec.get("palette_id")
project_palette_id = plan_palette.get("palette_id")
if isinstance(raw_palette, str) and isinstance(project_palette_id, str) and raw_palette and raw_palette != project_palette_id:
issues.append(issue("slide_palette_drift", "canvas_spec.palette_id must match project_palette.palette_id", path=f"slides[{index}].canvas_spec.palette_id"))
return issues
def recent_project_dirs(project: Path, limit: int) -> list[Path]:
root = project.parent
if not root.exists():
return []
candidates = [item for item in root.iterdir() if item.is_dir() and item.resolve() != project.resolve() and (item / PLAN_PATH).exists()]
candidates.sort(key=lambda item: (item / PLAN_PATH).stat().st_mtime, reverse=True)
return candidates[:limit]
def combo_reuse_ratio(left: list[str], right: list[str]) -> float:
if not left or not right:
return 0.0
left_counts = Counter(left)
right_counts = Counter(right)
overlap = sum(min(left_counts[key], right_counts[key]) for key in left_counts.keys() & right_counts.keys())
return overlap / min(len(left), len(right))
def compare_recent(project: Path, signature: dict[str, Any], *, history_limit: int) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
comparisons: list[dict[str, Any]] = []
issues: list[dict[str, str]] = []
current_keys = list(signature.get("combo_keys") if isinstance(signature.get("combo_keys"), list) else [])
for other_project in recent_project_dirs(project, history_limit):
other_plan = read_json_optional(other_project / PLAN_PATH)
if not other_plan:
continue
other_selection = read_json_optional(other_project / DESIGN_SELECTION_PATH)
other_signature = plan_signature(other_plan, other_selection)
other_keys = list(other_signature.get("combo_keys") if isinstance(other_signature.get("combo_keys"), list) else [])
ratio = combo_reuse_ratio([str(item) for item in current_keys], [str(item) for item in other_keys])
comparison = {
"project": relpath(other_project, project.parent),
"title": other_plan.get("title"),
"combo_reuse_ratio": round(ratio, 4),
"style_pack_id": other_signature.get("style_pack_id"),
"template_family_id": other_signature.get("template_family_id"),
}
comparisons.append(comparison)
if ratio >= COMBO_REUSE_FAIL:
issues.append(
issue(
"diversity_combo_reuse_too_high",
f"template/style/layout/component combo reuse ratio {ratio:.2f} exceeds {COMBO_REUSE_FAIL:.2f}",
compared_project=comparison["project"],
)
)
return comparisons, issues
def run_diversity_gate(project: Path, *, history_limit: int = DEFAULT_HISTORY_LIMIT) -> dict[str, Any]:
project = project.resolve()
started_at = now_iso()
plan = read_json_object(project / PLAN_PATH)
design_selection = read_json_optional(project / DESIGN_SELECTION_PATH)
signature = plan_signature(plan, design_selection)
issues = validate_current_plan(plan, design_selection, signature)
comparisons, comparison_issues = compare_recent(project, signature, history_limit=history_limit)
issues.extend(comparison_issues)
status = "passed" if not issues else "failed"
return {
"schema_version": "svglide-diversity-gate/v1",
"stage": "diversity_gate",
"status": status,
"action": PASS_ACTION if status == "passed" else FAIL_ACTION,
"project": str(project),
"started_at": started_at,
"ended_at": now_iso(),
"inputs": {
"slide_plan": PLAN_PATH.as_posix(),
"design_selection": DESIGN_SELECTION_PATH.as_posix() if (project / DESIGN_SELECTION_PATH).exists() else None,
"history_limit": history_limit,
},
"signature": signature,
"comparisons": comparisons,
"summary": {
"error_count": len(issues),
"comparison_count": len(comparisons),
"combo_count": len(signature.get("combos") if isinstance(signature.get("combos"), list) else []),
},
"issues": issues,
"output_path": OUTPUT_PATH.as_posix(),
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Review SVGlide design asset composition diversity.")
parser.add_argument("project")
parser.add_argument("--history-limit", type=int, default=DEFAULT_HISTORY_LIMIT)
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args(argv)
try:
result = run_diversity_gate(Path(args.project), history_limit=args.history_limit)
except DiversityGateError as error:
print(f"svglide_diversity_gate: {error}", file=sys.stderr)
return 2
write_json(Path(args.project) / OUTPUT_PATH, result)
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None, sort_keys=True))
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_diversity_gate as gate
def write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def design_selection(style_pack_id: str = "corporate_blue_data", palette_id: str = "corporate_blue") -> dict[str, object]:
return {
"schema_version": "svglide-design-asset-selection/v1",
"status": "passed",
"style_pack_selection": {"selected_style_pack_id": style_pack_id, "palette_id": palette_id},
"style_lock": {
"deck_level": True,
"template_family_id": "blue-professional",
"style_pack_id": style_pack_id,
"palette_id": palette_id,
"image_treatment_id": "chart_first",
"decoration_policy_id": "minimal_grid_only",
},
}
def plan_payload(style_pack_id: str = "corporate_blue_data", palette_id: str = "style_pack.corporate_blue_data") -> dict[str, object]:
return {
"title": "内部业务复盘",
"template_family_selection": {
"enabled": True,
"source": "beautiful-html-template-families",
"selected_template_id": "blue-professional",
"candidate_template_ids": ["blue-professional"],
"selection_reason": "test",
},
"style_lock": {
"deck_level": True,
"template_family_id": "blue-professional",
"style_pack_id": style_pack_id,
"palette_id": "corporate_blue",
"image_treatment_id": "chart_first",
"decoration_policy_id": "minimal_grid_only",
},
"project_palette": {
"palette_id": palette_id,
"source": "style_pack_registry",
"confidence": "high",
"style_pack_id": style_pack_id,
"colors": {"background": "#F8FAFF", "text": "#111827", "primary": "#1E3A8A", "accent": "#2563EB"},
},
"component_variant_selection": {"selected_component_variants": ["metric_cards", "comparison_matrix"]},
"slides": [
{
"page": 1,
"layout_family": "cover",
"template_variant": "cover",
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
"component_selection": [{"component_id": "title_block"}],
},
{
"page": 2,
"layout_family": "dashboard",
"template_variant": "kpi_strip",
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
"component_selection": [{"component_id": "metric_cards"}],
},
{
"page": 3,
"layout_family": "closing",
"template_variant": "closing",
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
"component_selection": [{"component_id": "action_list"}],
},
],
}
def write_project(project: Path, plan: dict[str, object] | None = None, selection: dict[str, object] | None = None) -> None:
write_json(project / "02-plan/slide_plan.json", plan or plan_payload())
write_json(project / "02-plan/selection-metadata.json", selection or design_selection())
class SVGlideDiversityGateTest(unittest.TestCase):
def test_passes_with_deck_level_style_lock_and_varied_layouts(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir) / "current"
write_project(project)
result = gate.run_diversity_gate(project)
self.assertEqual(result["status"], "passed", result["issues"])
self.assertEqual(result["summary"]["combo_count"], 3)
def test_fails_missing_style_lock(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir) / "current"
plan = plan_payload()
plan.pop("style_lock")
write_json(project / "02-plan/slide_plan.json", plan)
result = gate.run_diversity_gate(project)
self.assertEqual(result["status"], "failed")
self.assertIn("style_lock_missing", {item["code"] for item in result["issues"]})
def test_fails_slide_palette_drift(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir) / "current"
plan = plan_payload()
slides = plan["slides"] # type: ignore[index]
slides[1]["canvas_spec"]["palette_id"] = "style_pack.graphite_red_risk" # type: ignore[index]
write_project(project, plan=plan)
result = gate.run_diversity_gate(project)
self.assertEqual(result["status"], "failed")
self.assertIn("slide_palette_drift", {item["code"] for item in result["issues"]})
def test_fails_recent_combo_reuse_too_high(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
previous = root / "previous"
current = root / "current"
write_project(previous)
write_project(current)
result = gate.run_diversity_gate(current)
self.assertEqual(result["status"], "failed")
self.assertIn("diversity_combo_reuse_too_high", {item["code"] for item in result["issues"]})
if __name__ == "__main__":
unittest.main()

View File

@@ -56,7 +56,7 @@ def validate_brand_resolution(selection: dict[str, Any]) -> list[dict[str, Any]]
if not isinstance(resolution, dict):
return [issue("brand_resolution_missing", "palette selection must include brand_resolution", path="brand_resolution")]
issues: list[dict[str, Any]] = []
if resolution.get("source") not in {"user_provided", "brand_registry", "source_asset_extract", "official_lookup", "stable_fallback"}:
if resolution.get("source") not in {"user_provided", "brand_registry", "source_asset_extract", "official_lookup", "stable_fallback", "style_pack_registry"}:
issues.append(issue("brand_resolution_source_invalid", "brand_resolution.source is invalid", path="brand_resolution.source"))
if resolution.get("confidence") not in {"high", "medium", "low"}:
issues.append(issue("brand_resolution_confidence_invalid", "brand_resolution.confidence is invalid", path="brand_resolution.confidence"))

View File

@@ -21,6 +21,57 @@ REPO_ROOT = SCRIPT_DIR.parents[2]
INSTRUCTION_PATH = Path("00-input/instruction.json")
PALETTE_SELECTION_PATH = Path("02-plan/palette-selection.json")
PALETTE_RECEIPT_PATH = Path("receipts/palette_selection.json")
DESIGN_SELECTION_PATH = Path("02-plan/selection-metadata.json")
STYLE_PACK_PALETTE_TOKENS = {
"corporate_blue_data": {
"colors": {"background": "#F8FAFF", "surface": "#EEF3FF", "panel": "#FFFFFF", "primary": "#1E3A8A", "accent": "#2563EB", "text": "#111827", "muted": "#64748B", "border": "#CBD5E1", "success": "#059669", "warning": "#D97706", "danger": "#DC2626"},
"data_series": ["#2563EB", "#1D4ED8", "#059669", "#D97706", "#DC2626"],
},
"product_ai_indigo_cyan": {
"colors": {"background": "#F5F7FF", "surface": "#EAF2FF", "panel": "#FFFFFF", "primary": "#3730A3", "accent": "#0891B2", "text": "#101828", "muted": "#526175", "border": "#C7D2FE", "success": "#0F766E", "warning": "#B45309", "danger": "#BE123C"},
"data_series": ["#3730A3", "#0891B2", "#7C3AED", "#0F766E", "#B45309"],
},
"teal_amber_growth": {
"colors": {"background": "#F3FAF8", "surface": "#E3F6F2", "panel": "#FFFFFF", "primary": "#0F766E", "accent": "#D97706", "text": "#10201D", "muted": "#5B6F6B", "border": "#B7DED8", "success": "#059669", "warning": "#D97706", "danger": "#DC2626"},
"data_series": ["#0F766E", "#14B8A6", "#D97706", "#F59E0B", "#2563EB"],
},
"graphite_red_risk": {
"colors": {"background": "#F6F7F8", "surface": "#ECEFF1", "panel": "#FFFFFF", "primary": "#374151", "accent": "#DC2626", "text": "#111827", "muted": "#6B7280", "border": "#D1D5DB", "success": "#059669", "warning": "#D97706", "danger": "#DC2626"},
"data_series": ["#374151", "#DC2626", "#991B1B", "#D97706", "#6B7280"],
},
"food_culture_warm_editorial": {
"colors": {"background": "#FFF7ED", "surface": "#FFEAD5", "panel": "#FFFFFF", "primary": "#B45309", "accent": "#0F766E", "text": "#3B2416", "muted": "#7C5C46", "border": "#FED7AA", "success": "#15803D", "warning": "#D97706", "danger": "#B91C1C"},
"data_series": ["#B45309", "#EA580C", "#0F766E", "#DB2777", "#7C2D12"],
},
"culture_editorial_jade_magenta": {
"colors": {"background": "#F8F4EF", "surface": "#EFE7DD", "panel": "#FFFFFF", "primary": "#0F766E", "accent": "#BE185D", "text": "#211A17", "muted": "#6F5E56", "border": "#DECFC2", "success": "#15803D", "warning": "#B45309", "danger": "#BE123C"},
"data_series": ["#0F766E", "#BE185D", "#2563EB", "#B45309", "#6D28D9"],
},
"investor_navy_gold": {
"colors": {"background": "#F7F6F1", "surface": "#ECE8DC", "panel": "#FFFFFF", "primary": "#14213D", "accent": "#B0892F", "text": "#151515", "muted": "#667085", "border": "#D8D0BA", "success": "#047857", "warning": "#B0892F", "danger": "#B91C1C"},
"data_series": ["#14213D", "#B0892F", "#2563EB", "#047857", "#B91C1C"],
},
"education_sky_green": {
"colors": {"background": "#F4FAFF", "surface": "#E4F2FF", "panel": "#FFFFFF", "primary": "#0369A1", "accent": "#16A34A", "text": "#102033", "muted": "#5F7186", "border": "#BAE6FD", "success": "#16A34A", "warning": "#D97706", "danger": "#DC2626"},
"data_series": ["#0369A1", "#16A34A", "#0EA5E9", "#D97706", "#7C3AED"],
},
"event_neon_cyan_magenta": {
"colors": {"background": "#111827", "surface": "#1F2937", "panel": "#172033", "primary": "#22D3EE", "accent": "#EC4899", "text": "#F8FAFC", "muted": "#CBD5E1", "border": "#334155", "success": "#34D399", "warning": "#F59E0B", "danger": "#FB7185"},
"data_series": ["#22D3EE", "#EC4899", "#8B5CF6", "#F59E0B", "#34D399"],
},
"scholarly_vellum_ink": {
"colors": {"background": "#F8F4E8", "surface": "#EFE8D6", "panel": "#FFFDF6", "primary": "#1F2937", "accent": "#1D4ED8", "text": "#111827", "muted": "#6B6258", "border": "#D8CCB3", "success": "#047857", "warning": "#B45309", "danger": "#B91C1C"},
"data_series": ["#1F2937", "#1D4ED8", "#64748B", "#B45309", "#047857"],
},
"workshop_playful_paper": {
"colors": {"background": "#FFF7E6", "surface": "#FDECC8", "panel": "#FFFFFF", "primary": "#2563EB", "accent": "#F97316", "text": "#1F2937", "muted": "#6B7280", "border": "#F3D9A4", "success": "#16A34A", "warning": "#F97316", "danger": "#DC2626"},
"data_series": ["#2563EB", "#F97316", "#16A34A", "#DB2777", "#7C3AED"],
},
"architecture_mono_cyan": {
"colors": {"background": "#F3F7FA", "surface": "#E6EEF3", "panel": "#FFFFFF", "primary": "#111827", "accent": "#0891B2", "text": "#0F172A", "muted": "#5B6B7C", "border": "#CBD5E1", "success": "#0F766E", "warning": "#B45309", "danger": "#B91C1C"},
"data_series": ["#111827", "#0891B2", "#475569", "#0F766E", "#B45309"],
},
}
def now_iso() -> str:
@@ -48,6 +99,88 @@ def load_palette_registry() -> dict[str, Any]:
return beautiful_template_runtime.palette_registry()
def load_design_selection(project_root: Path) -> dict[str, Any]:
path = project_root / DESIGN_SELECTION_PATH
if not path.exists():
return {}
try:
return read_json(path)
except (OSError, json.JSONDecodeError, ValueError):
return {}
def selected_style_pack(design_selection: dict[str, Any]) -> dict[str, Any]:
if design_selection.get("status") != "passed":
return {}
selection = design_selection.get("style_pack_selection")
lock = design_selection.get("style_lock")
if not isinstance(selection, dict) or not isinstance(lock, dict):
return {}
selected_id = selection.get("selected_style_pack_id")
if not isinstance(selected_id, str) or not selected_id:
return {}
if lock.get("style_pack_id") not in (None, selected_id):
return {}
return selection
def style_pack_palette_result(
project_root: Path,
brief: str,
signals: dict[str, Any],
style_pack: dict[str, Any],
*,
top_k: int,
) -> dict[str, Any] | None:
style_pack_id = str(style_pack.get("selected_style_pack_id") or "")
tokens = STYLE_PACK_PALETTE_TOKENS.get(style_pack_id)
if not tokens:
return None
palette_id = f"style_pack.{style_pack_id}"
colors = dict(tokens.get("colors") if isinstance(tokens.get("colors"), dict) else {})
data_series = list(tokens.get("data_series") if isinstance(tokens.get("data_series"), list) else [])
brand_resolution = {
"source": "style_pack_registry",
"confidence": "high",
"style_pack_id": style_pack_id,
"palette_id": style_pack.get("palette_id"),
"colors": colors,
"reason": "selected by deck recipe/style_pack metadata",
"evidence": [DESIGN_SELECTION_PATH.as_posix()],
}
candidate = {
"palette_id": palette_id,
"score": 100,
"matched_signals": ["style_pack:selected", f"style_pack:{style_pack_id}"],
"missed_signals": [],
"selection_reason": ["style_pack:selected", f"style_pack:{style_pack_id}"],
"rejection_reasons": [],
}
project_palette = {
"palette_id": palette_id,
"source": "style_pack_registry",
"confidence": "high",
"selection_receipt": PALETTE_SELECTION_PATH.as_posix(),
"colors": colors,
"data_series": data_series,
"style_pack_id": style_pack_id,
}
return {
"schema_version": SCHEMA_VERSION,
"stage": "palette_selection",
"created_at": now_iso(),
"brief_signals": signals,
"selected_palette_id": palette_id,
"confidence": "high",
"fallback_policy": "not_used",
"deterministic_seed": stable_seed({"brief": brief, "signals": signals, "style_pack_id": style_pack_id}),
"brand_resolution": brand_resolution,
"palette_candidates": [candidate][:top_k],
"candidate_palette_ids_considered": [palette_id],
"project_palette": project_palette,
}
def project_brief(project_root: Path, explicit_brief: str | None = None) -> str:
if explicit_brief:
return explicit_brief
@@ -188,6 +321,16 @@ def select_palette(project_root: Path, brief: str, *, top_k: int = 5, evidence:
registry = load_palette_registry()
signals = semantic_matcher.infer_brief_signals(brief)
brand_resolution = brand_resolver.resolve_brand_palette(project_root, brief, evidence)
if brand_resolution.get("source") not in {"brand_registry", "user_provided"}:
style_pack_result = style_pack_palette_result(
project_root,
brief,
signals,
selected_style_pack(load_design_selection(project_root)),
top_k=top_k,
)
if style_pack_result is not None:
return style_pack_result
scored = [
score_palette(signals, palette, brand_resolution)
for palette in registry.get("palettes", [])

View File

@@ -54,6 +54,37 @@ class PaletteSelectorTest(unittest.TestCase):
self.assertEqual("#123456", result["project_palette"]["colors"]["primary"])
self.assertEqual("#ABCDEF", result["project_palette"]["colors"]["accent"])
def test_style_pack_palette_wins_over_stable_fallback(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "02-plan").mkdir(parents=True, exist_ok=True)
(root / "02-plan/selection-metadata.json").write_text(
json.dumps(
{
"schema_version": "svglide-design-asset-selection/v1",
"status": "passed",
"style_pack_selection": {
"selected_style_pack_id": "corporate_blue_data",
"palette_id": "corporate_blue",
},
"style_lock": {
"style_pack_id": "corporate_blue_data",
"palette_id": "corporate_blue",
"deck_level": True,
},
}
),
encoding="utf-8",
)
result = selector.select_palette(root, "生成一份内部业务复盘,高管经营看板", top_k=3)
self.assertEqual("style_pack_registry", result["brand_resolution"]["source"])
self.assertEqual("style_pack.corporate_blue_data", result["selected_palette_id"])
self.assertEqual("style_pack_registry", result["project_palette"]["source"])
self.assertNotIn("quality_gate_fallback", result["brand_resolution"])
self.assertNotIn("quality_gate_fallback", result["project_palette"])
def test_unknown_topic_is_stable(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)

View File

@@ -61,6 +61,7 @@ STAGES = [
"semantic_review",
"runtime_review",
"visual_distinctness_review",
"diversity_gate",
"theme_adherence",
"quality_gate",
"generation_benchmark",
@@ -98,6 +99,8 @@ STAGE_ALIASES = {
"runtime-review": "runtime_review",
"visual-distinctness": "visual_distinctness_review",
"visual-distinctness-review": "visual_distinctness_review",
"diversity-gate": "diversity_gate",
"diversity-review": "diversity_gate",
"theme-adherence": "theme_adherence",
"generate": "generate_svg",
"generate-svg": "generate_svg",
@@ -159,6 +162,7 @@ IMPLEMENTED_STAGES = {
"semantic_review",
"runtime_review",
"visual_distinctness_review",
"diversity_gate",
"theme_adherence",
"quality_gate",
"generation_benchmark",
@@ -1095,6 +1099,17 @@ def run_script_stage(
command=command,
wall_time_seconds=time.perf_counter() - started_perf,
)
parsed_success = parse_json_or_none(completed.stdout)
if stage == "contract_compile" and isinstance(parsed_success, dict):
receipt["contract_manifest"] = parsed_success.get("contract_manifest") or "04-svg/contract/manifest.json"
receipt["raw_visual_manifest_sha256"] = parsed_success.get("raw_visual_manifest_sha256") or optional_project_file_hash(
project_root, "04-artboard/raw/manifest.json"
)
if "asset_injection_summary" in parsed_success:
receipt["asset_injection_summary"] = parsed_success["asset_injection_summary"]
if "summary" in parsed_success:
receipt["contract_summary"] = parsed_success["summary"]
write_json(receipt_path(project_root, stage), receipt)
if repair_result is not None:
receipt["auto_repair"] = repair_result
write_json(receipt_path(project_root, stage), receipt)
@@ -1224,6 +1239,32 @@ def theme_template_selection_path(project_root: Path) -> Path:
return project_root / "02-plan" / "theme-template-selection.json"
def design_selection_path(project_root: Path) -> Path:
return project_root / "02-plan" / "selection-metadata.json"
def recipe_routing_receipt_path(project_root: Path) -> Path:
return project_root / "02-plan" / "recipe-routing-receipt.json"
def project_prompt(project_root: Path) -> str:
instruction = read_json_optional(project_root / "00-input" / "instruction.json")
for key in ["raw_prompt", "prompt", "brief", "instruction"]:
value = instruction.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
plan = read_json_optional(plan_path(project_root))
text_parts = [str(plan.get(key) or "") for key in ["title", "topic", "scenario", "audience"]]
slides = plan.get("slides")
if isinstance(slides, list):
for slide in slides[:3]:
if isinstance(slide, dict):
text_parts.extend(str(slide.get(key) or "") for key in ["title", "key_message", "section", "role"])
manifest = read_json_optional(project_root / "01-project" / "project_manifest.json")
text_parts.append(str(manifest.get("title") or ""))
return " ".join(part.strip() for part in text_parts if part and part.strip())
def plan_declares_selection(project_root: Path) -> bool:
path = plan_path(project_root)
if not path.exists():
@@ -1237,11 +1278,45 @@ def plan_declares_selection(project_root: Path) -> bool:
return bool(
payload.get("selection_receipt")
or payload.get("palette_selection_receipt")
or payload.get("selection_metadata_receipt")
or payload.get("recipe_routing_receipt")
or payload.get("route") == ROUTE
or payload.get("output_mode") == ROUTE
or isinstance(payload.get("project_palette"), dict)
or isinstance(payload.get("project_theme"), dict)
)
def plan_has_selection_artifacts(project_root: Path) -> bool:
path = plan_path(project_root)
if not path.exists():
return False
try:
payload = read_json(path)
except (OSError, json.JSONDecodeError):
return False
if not isinstance(payload, dict):
return False
if (
payload.get("selection_receipt")
or payload.get("palette_selection_receipt")
or payload.get("selection_metadata_receipt")
or payload.get("recipe_routing_receipt")
or isinstance(payload.get("project_palette"), dict)
or isinstance(payload.get("project_theme"), dict)
):
return True
slides = payload.get("slides")
if isinstance(slides, list):
for slide in slides:
if not isinstance(slide, dict):
continue
spec = slide.get("canvas_spec")
if isinstance(spec, dict) and (spec.get("palette_id") or spec.get("selection_trace")):
return True
return False
def selection_gate_required(project_root: Path, state: dict[str, Any]) -> bool:
stages = state.get("stages") if isinstance(state.get("stages"), dict) else {}
return bool(
@@ -1250,13 +1325,15 @@ def selection_gate_required(project_root: Path, state: dict[str, Any]) -> bool:
or "selection_review" in stages
or palette_selection_path(project_root).exists()
or theme_template_selection_path(project_root).exists()
or plan_declares_selection(project_root)
or design_selection_path(project_root).exists()
or plan_has_selection_artifacts(project_root)
)
def apply_selection_receipts_to_plan(project_root: Path, plan: dict[str, Any]) -> bool:
palette_path = palette_selection_path(project_root)
selection_path = theme_template_selection_path(project_root)
design_path = design_selection_path(project_root)
if not palette_path.exists() or not selection_path.exists():
return False
try:
@@ -1275,8 +1352,32 @@ def apply_selection_receipts_to_plan(project_root: Path, plan: dict[str, Any]) -
if plan.get("selection_receipt") != "02-plan/theme-template-selection.json":
plan["selection_receipt"] = "02-plan/theme-template-selection.json"
changed = True
if design_path.exists():
try:
design_selection = read_json(design_path)
except (OSError, json.JSONDecodeError):
design_selection = {}
if isinstance(design_selection, dict) and design_selection.get("status") == "passed":
for key in [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
if isinstance(design_selection.get(key), dict) and plan.get(key) != design_selection[key]:
plan[key] = design_selection[key]
changed = True
if plan.get("selection_metadata_receipt") != "02-plan/selection-metadata.json":
plan["selection_metadata_receipt"] = "02-plan/selection-metadata.json"
changed = True
if plan.get("recipe_routing_receipt") != "02-plan/recipe-routing-receipt.json":
plan["recipe_routing_receipt"] = "02-plan/recipe-routing-receipt.json"
changed = True
if selection.get("confidence") == "low" and not plan.get("fallback_policy"):
plan["fallback_policy"] = "auto"
plan["fallback_policy"] = "strict-native"
plan["selection_fallback_policy"] = {
"reason": "low_confidence_theme_template_selection",
"selection_fallback_policy": selection.get("fallback_policy") or "deterministic_ranked_fallback",
@@ -1465,6 +1566,10 @@ def run_plan_stage(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
receipt["plan_sha256"] = file_sha256(plan)
receipt["visual_identity_added"] = bool(locals().get("visual_identity_added", False))
receipt["selection_receipts_applied"] = bool(locals().get("selection_applied", False))
receipt["design_asset_selection_applied"] = bool(
payload.get("selection_metadata_receipt") == "02-plan/selection-metadata.json"
and isinstance(payload.get("style_lock"), dict)
)
receipt["summary"] = {"error_count": len(schema_issues)}
receipt["issues"] = schema_issues
write_json(receipt_path(project_root, "plan"), receipt)
@@ -1475,10 +1580,25 @@ def run_plan_stage(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
def run_select_style_stage(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
started_at = now_iso()
prompt = project_prompt(project_root)
commands = [
[
"python3",
(SCRIPT_DIR / "svglide_recipe_selector.py").as_posix(),
"--prompt",
prompt,
"--out",
design_selection_path(project_root).as_posix(),
],
["python3", (SCRIPT_DIR / "svglide_palette_selector.py").as_posix(), project_root.as_posix(), "--pretty"],
["python3", (SCRIPT_DIR / "svglide_theme_template_selector.py").as_posix(), project_root.as_posix(), "--pretty"],
]
receipt_command = [
"select_style_pipeline",
(SCRIPT_DIR / "svglide_recipe_selector.py").as_posix(),
(SCRIPT_DIR / "svglide_palette_selector.py").as_posix(),
(SCRIPT_DIR / "svglide_theme_template_selector.py").as_posix(),
]
stdout_parts: list[str] = []
stderr_parts: list[str] = []
for command in commands:
@@ -1493,8 +1613,8 @@ def run_select_style_stage(project_root: Path, state: dict[str, Any]) -> dict[st
"failed",
started_at=started_at,
inputs=["00-input/instruction.json", "source/evidence.json"],
outputs=["02-plan/palette-selection.json", "02-plan/theme-template-selection.json"],
command=[" / ".join(shlex.join(item) for item in commands)],
outputs=["02-plan/selection-metadata.json", "02-plan/recipe-routing-receipt.json", "02-plan/palette-selection.json", "02-plan/theme-template-selection.json"],
command=receipt_command,
error={
"code": "stage_command_failed",
"returncode": completed.returncode,
@@ -1510,13 +1630,25 @@ def run_select_style_stage(project_root: Path, state: dict[str, Any]) -> dict[st
started_at=started_at,
inputs=["00-input/instruction.json", "source/evidence.json"],
outputs=[
"02-plan/selection-metadata.json",
"02-plan/recipe-routing-receipt.json",
"02-plan/palette-selection.json",
"02-plan/theme-template-selection.json",
"receipts/recipe_selection.json",
"receipts/palette_selection.json",
"receipts/theme_template_selection.json",
],
command=[" / ".join(shlex.join(item) for item in commands)],
command=receipt_command,
)
design_selection = read_json(design_selection_path(project_root))
write_json(recipe_routing_receipt_path(project_root), design_selection)
write_json(project_root / "receipts" / "recipe_selection.json", design_selection)
receipt["recipe_selection"] = {
"recipe_id": design_selection.get("deck_recipe_selection", {}).get("recipe_id") if isinstance(design_selection.get("deck_recipe_selection"), dict) else None,
"match_level": design_selection.get("deck_recipe_selection", {}).get("match_level") if isinstance(design_selection.get("deck_recipe_selection"), dict) else None,
"style_pack_id": design_selection.get("style_pack_selection", {}).get("selected_style_pack_id") if isinstance(design_selection.get("style_pack_selection"), dict) else None,
"image_treatment_id": design_selection.get("image_treatment_selection", {}).get("selected_image_treatment_id") if isinstance(design_selection.get("image_treatment_selection"), dict) else None,
}
receipt["stdout"] = "\n".join(part.strip() for part in stdout_parts if part.strip())
receipt["stderr"] = "\n".join(part.strip() for part in stderr_parts if part.strip())
write_json(receipt_path(project_root, "select_style"), receipt)
@@ -2138,11 +2270,12 @@ def require_quality_gate_current(project_root: Path) -> dict[str, Any]:
raise RunnerError("quality gate is missing theme-validate check; rerun quality_gate")
if "theme-adherence" not in check_names:
raise RunnerError("quality gate is missing theme-adherence check; rerun quality_gate")
if plan_declares_selection(project_root):
if plan_has_selection_artifacts(project_root):
for input_name, rel, check_name in [
("palette_review", "06-check/palette-review.json", "palette-review"),
("theme_template_selection_review", "06-check/theme-template-selection-review.json", "theme-template-selection-review"),
("plan_bundle_review", "06-check/plan-bundle-review.json", "plan-bundle-review"),
("diversity_gate", "06-check/diversity-gate.json", "diversity-gate"),
]:
if inputs.get(input_name) != rel:
raise RunnerError(f"quality gate is missing {input_name} input; rerun selection_review and quality_gate")
@@ -2175,6 +2308,7 @@ def require_quality_gate_current(project_root: Path) -> dict[str, Any]:
("palette_review", "06-check/palette-review.json"),
("theme_template_selection_review", "06-check/theme-template-selection-review.json"),
("plan_bundle_review", "06-check/plan-bundle-review.json"),
("diversity_gate", "06-check/diversity-gate.json"),
]:
if input_hashes.get(input_name) != optional_project_file_hash(project_root, rel):
raise RunnerError(f"quality gate {input_name} hash is stale; rerun quality_gate")
@@ -2999,6 +3133,17 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
inputs=["02-plan/slide_plan.json"],
outputs=["06-check/visual-distinctness.json"],
)
if stage == "diversity_gate":
require_stage_passed(state, "visual_distinctness_review")
return run_script_stage(
project_root,
state,
stage,
["python3", (SCRIPT_DIR / "svglide_diversity_gate.py").as_posix(), project_root.as_posix(), "--pretty"],
output_json=project_root / "06-check" / "diversity-gate.json",
inputs=["02-plan/slide_plan.json", "02-plan/selection-metadata.json"],
outputs=["06-check/diversity-gate.json"],
)
if stage == "theme_adherence":
require_stage_passed(state, "visual_distinctness_review")
require_stage_passed(state, "theme_validate")
@@ -3015,6 +3160,7 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
if selection_gate_required(project_root, state):
require_stage_passed(state, "palette_review")
require_stage_passed(state, "selection_review")
require_stage_passed(state, "diversity_gate")
require_stage_passed(state, "preflight")
require_stage_passed(state, "preview_lint")
require_stage_passed(state, "aesthetic_review")
@@ -3041,6 +3187,7 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
"06-check/palette-review.json",
"06-check/theme-template-selection-review.json",
"06-check/plan-bundle-review.json",
"06-check/diversity-gate.json",
"receipts/generate_svg.json",
],
outputs=["06-check/quality-gate.json"],
@@ -3073,6 +3220,9 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
if artboard_visual_acceptance_required(project_root):
require_stage_passed(state, "visual_acceptance")
require_visual_acceptance_current(project_root)
ppe_outputs = ["07-create/ppe-proof.json", "07-create/ppe-create-probe.json"]
if project_has_image_assets(project_root):
ppe_outputs.append("07-create/ppe-image-probe.json")
return run_script_stage(
project_root,
state,
@@ -3080,7 +3230,7 @@ def run_implemented_stage(project_root: Path, stage: str, state: dict[str, Any],
["python3", (SCRIPT_DIR / "svglide_ppe_proof.py").as_posix(), project_root.as_posix(), "--pretty"],
output_json=project_root / "07-create" / "ppe-proof.json",
inputs=["06-check/quality-gate.json", "07-create/dry-run.json", "07-create/ppe-proof.input.json", "03-assets/assets.json"],
outputs=["07-create/ppe-proof.json", "07-create/ppe-create-probe.json", "07-create/ppe-image-probe.json"],
outputs=ppe_outputs,
)
if stage == "pre_submit_review":
require_stage_passed(state, "ppe_proof")
@@ -3168,7 +3318,9 @@ def run_stage(project_root: Path, stage: str, *, command: list[str] | None = Non
fail_if_existing_stage_failed(normalized, record)
require_existing_stage_current(project_root, normalized, profile=profile)
write_timing_report(project_root, state)
return {"stage": normalized, "status": "passed", "state": state}
response = read_json_optional(receipt_path(project_root, normalized))
response.update({"stage": normalized, "status": "passed", "state": state})
return response
if normalized not in IMPLEMENTED_STAGES:
block_unimplemented_stage(
@@ -3179,7 +3331,9 @@ def run_stage(project_root: Path, stage: str, *, command: list[str] | None = Non
)
receipt = run_implemented_stage(project_root, normalized, state, profile=profile)
return {"stage": normalized, "status": receipt["status"], "state": load_state(project_root)}
response = dict(receipt)
response.update({"stage": normalized, "status": receipt["status"], "state": load_state(project_root)})
return response
def collected_failure_from_stage(project_root: Path, stage: str, err: RunnerError) -> dict[str, Any]:

View File

@@ -16,6 +16,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svg_preflight
import beautiful_template_runtime
import svglide_project_runner as runner
@@ -201,6 +202,14 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
def write_artboard_plan(self, project_root: Path) -> None:
self.write_plan(project_root)
(project_root / "02-plan/theme-registry.json").write_text(
json.dumps(beautiful_template_runtime.theme_registry(include_legacy=True)),
encoding="utf-8",
)
(project_root / "02-plan/template-registry.json").write_text(
json.dumps(beautiful_template_runtime.template_registry(include_legacy=True)),
encoding="utf-8",
)
plan = json.loads((project_root / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["generation_mode"] = "artboard_satori"
first_slide = plan["slides"][0]
@@ -540,6 +549,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
"semantic_review",
"runtime_review",
"visual_distinctness_review",
"diversity_gate",
"theme_adherence",
"quality_gate",
]:
@@ -656,6 +666,64 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
def completed(self, command: list[str], payload: dict[str, object] | None = None, returncode: int = 0) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(command, returncode, stdout=json.dumps(payload or {"ok": True}), stderr="")
def test_run_script_stage_merges_contract_compile_receipt_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("smoke", "Smoke", plan_root=plan_root)
project_root = Path(result["project_root"])
state = runner.load_state(project_root)
payload = {
"stage": "contract_compile",
"status": "passed",
"contract_manifest": "04-svg/contract/manifest.json",
"raw_visual_manifest_sha256": "raw-hash",
"asset_injection_summary": {"status": "passed", "used_count": 1},
}
receipt = runner.run_script_stage(
project_root,
state,
"contract_compile",
["fake-contract-compile"],
command_runner=lambda command, **_: self.completed(command, payload),
)
self.assertEqual(receipt["contract_manifest"], "04-svg/contract/manifest.json")
self.assertEqual(receipt["raw_visual_manifest_sha256"], "raw-hash")
self.assertEqual(receipt["asset_injection_summary"]["used_count"], 1)
saved = json.loads((project_root / "receipts/contract_compile.json").read_text(encoding="utf-8"))
self.assertEqual(saved["raw_visual_manifest_sha256"], "raw-hash")
def test_select_style_writes_recipe_selection_receipts_and_plan_applies_them(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
plan_root = Path(tmpdir) / ".lark-slides/plan"
result = runner.init_project("smoke", "Smoke", plan_root=plan_root)
project_root = Path(result["project_root"])
brief = "豆包 App 竞品分析,关注产品能力、用户场景和真实产品截图"
self.write_instruction(project_root, brief)
self.run_source(project_root)
self.write_selection_ready_plan(project_root, brief)
select_result = runner.run_stage(project_root, "select_style")
self.assertEqual(select_result["status"], "passed")
selection_metadata = json.loads((project_root / "02-plan/selection-metadata.json").read_text(encoding="utf-8"))
routing_receipt = json.loads((project_root / "02-plan/recipe-routing-receipt.json").read_text(encoding="utf-8"))
select_receipt = json.loads((project_root / "receipts/select_style.json").read_text(encoding="utf-8"))
self.assertEqual(selection_metadata["deck_recipe_selection"]["recipe_id"], "consumer_ai_product_analysis")
self.assertEqual(routing_receipt["deck_recipe_selection"]["recipe_id"], "consumer_ai_product_analysis")
self.assertIn("02-plan/selection-metadata.json", select_receipt["outputs"])
self.assertIn("02-plan/recipe-routing-receipt.json", select_receipt["outputs"])
plan_result = runner.run_stage(project_root, "plan")
self.assertEqual(plan_result["status"], "passed")
plan = json.loads((project_root / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
self.assertEqual(plan["selection_metadata_receipt"], "02-plan/selection-metadata.json")
self.assertEqual(plan["recipe_routing_receipt"], "02-plan/recipe-routing-receipt.json")
self.assertEqual(plan["deck_recipe_selection"], selection_metadata["deck_recipe_selection"])
self.assertEqual(plan["style_lock"], selection_metadata["style_lock"])
plan_receipt = json.loads((project_root / "receipts/plan.json").read_text(encoding="utf-8"))
self.assertTrue(plan_receipt["design_asset_selection_applied"])
def write_ppe_input(self, project_root: Path) -> None:
rule_file = "skills/lark-slides/references/ppe-pure-svg.whistle.js"
rule_path = runner.repo_root() / rule_file
@@ -707,6 +775,8 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertEqual(runner.normalize_stage("runtime-review"), "runtime_review")
self.assertEqual(runner.normalize_stage("visual-distinctness"), "visual_distinctness_review")
self.assertEqual(runner.normalize_stage("visual-distinctness-review"), "visual_distinctness_review")
self.assertEqual(runner.normalize_stage("diversity-gate"), "diversity_gate")
self.assertEqual(runner.normalize_stage("diversity-review"), "diversity_gate")
self.assertEqual(runner.normalize_stage("theme-adherence"), "theme_adherence")
self.assertEqual(runner.normalize_stage("generate"), "generate_svg")
self.assertEqual(runner.normalize_stage("generate-svg"), "generate_svg")
@@ -743,6 +813,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertIn("semantic_review", dry_run)
self.assertIn("runtime_review", dry_run)
self.assertIn("visual_distinctness_review", dry_run)
self.assertIn("diversity_gate", dry_run)
self.assertIn("theme_adherence", dry_run)
self.assertIn("quality_gate", dry_run)
self.assertIn("generation_benchmark", dry_run)
@@ -807,6 +878,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertIn("semantic_review", called_stages)
self.assertIn("runtime_review", called_stages)
self.assertIn("visual_distinctness_review", called_stages)
self.assertIn("diversity_gate", called_stages)
self.assertIn("theme_validate", called_stages)
self.assertIn("package_check", called_stages)
self.assertIn("theme_adherence", called_stages)
@@ -821,8 +893,10 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertLess(called_stages.index("chart_verify"), called_stages.index("semantic_review"))
self.assertLess(called_stages.index("semantic_review"), called_stages.index("quality_gate"))
self.assertLess(called_stages.index("runtime_review"), called_stages.index("visual_distinctness_review"))
self.assertLess(called_stages.index("visual_distinctness_review"), called_stages.index("diversity_gate"))
self.assertLess(called_stages.index("visual_distinctness_review"), called_stages.index("theme_adherence"))
self.assertLess(called_stages.index("theme_adherence"), called_stages.index("quality_gate"))
self.assertLess(called_stages.index("diversity_gate"), called_stages.index("quality_gate"))
self.assertLess(called_stages.index("visual_distinctness_review"), called_stages.index("quality_gate"))
self.assertNotIn("dry_run", called_stages)
self.assertNotIn("ppe_proof", called_stages)
@@ -1586,6 +1660,11 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertTrue((project_root / "receipts/template-fit-check.json").exists())
compile_result = runner.run_stage(project_root, "contract_compile")
self.assertEqual(compile_result["status"], "passed")
self.assertEqual(compile_result["contract_manifest"], "04-svg/contract/manifest.json")
self.assertEqual(
compile_result["raw_visual_manifest_sha256"],
runner.file_sha256(project_root / "04-artboard/raw/manifest.json"),
)
self.assertTrue((project_root / "04-svg/page-001.svg").exists())
self.assertIn('slide:role="slide"', (project_root / "04-svg/page-001.svg").read_text(encoding="utf-8"))
self.assertTrue((project_root / "04-artboard/raw/page-001.visual.png").exists())
@@ -1730,6 +1809,59 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
with self.assertRaisesRegex(runner.RunnerError, "palette_review"):
runner.require_quality_gate_current(project_root)
def test_existing_quality_gate_with_stale_diversity_gate_is_stale(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)
plan_path = project_root / "02-plan/slide_plan.json"
plan = json.loads(plan_path.read_text(encoding="utf-8"))
plan["selection_metadata_receipt"] = "02-plan/selection-metadata.json"
plan["recipe_routing_receipt"] = "02-plan/recipe-routing-receipt.json"
plan["selection_receipt"] = "02-plan/theme-template-selection.json"
plan_path.write_text(json.dumps(plan), encoding="utf-8")
for rel, payload in {
"02-plan/selection-metadata.json": {"status": "passed"},
"02-plan/recipe-routing-receipt.json": {"status": "passed"},
"02-plan/theme-template-selection.json": {"status": "passed"},
"06-check/palette-review.json": {"status": "passed"},
"06-check/theme-template-selection-review.json": {"status": "passed"},
"06-check/plan-bundle-review.json": {"status": "passed"},
"06-check/diversity-gate.json": {"status": "passed", "version": 1},
}.items():
(project_root / rel).parent.mkdir(parents=True, exist_ok=True)
(project_root / rel).write_text(json.dumps(payload), encoding="utf-8")
gate_path = project_root / "06-check/quality-gate.json"
gate = json.loads(gate_path.read_text(encoding="utf-8"))
gate["inputs"].update(
{
"palette_review": "06-check/palette-review.json",
"theme_template_selection_review": "06-check/theme-template-selection-review.json",
"plan_bundle_review": "06-check/plan-bundle-review.json",
"diversity_gate": "06-check/diversity-gate.json",
}
)
gate["checks"].extend(
[
{"name": "palette-review", "status": "passed"},
{"name": "theme-template-selection-review", "status": "passed"},
{"name": "plan-bundle-review", "status": "passed"},
{"name": "diversity-gate", "status": "passed"},
]
)
for input_name, rel in [
("palette_review", "06-check/palette-review.json"),
("theme_template_selection_review", "06-check/theme-template-selection-review.json"),
("plan_bundle_review", "06-check/plan-bundle-review.json"),
("diversity_gate", "06-check/diversity-gate.json"),
]:
gate["input_hashes"][input_name] = runner.file_sha256(project_root / rel)
gate_path.write_text(json.dumps(gate), encoding="utf-8")
(project_root / "06-check/diversity-gate.json").write_text(json.dumps({"status": "passed", "version": 2}), encoding="utf-8")
with self.assertRaisesRegex(runner.RunnerError, "diversity_gate hash is stale"):
runner.require_quality_gate_current(project_root)
def test_dry_run_command_includes_assets_when_present(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = self.make_project(tmpdir)

View File

@@ -56,6 +56,7 @@ SELECTION_CHECKS = [
("palette-review", CHECK_DIR / "palette-review.json"),
("theme-template-selection-review", CHECK_DIR / "theme-template-selection-review.json"),
("plan-bundle-review", CHECK_DIR / "plan-bundle-review.json"),
("diversity-gate", CHECK_DIR / "diversity-gate.json"),
]
ARTBOARD_PACKAGE_CHECK = ("artboard-package-check", CHECK_DIR / "artboard-package-check.json")
CHART_VERIFY_CHECK = ("chart-verify", CHECK_DIR / "chart-verify.json")
@@ -631,8 +632,12 @@ def plan_declares_selection(project: Path) -> bool:
return False
if not isinstance(payload, dict):
return False
if payload.get("route") == "svglide-svg" or payload.get("output_mode") == "svglide-svg":
return True
if payload.get("selection_receipt") or payload.get("palette_selection_receipt"):
return True
if payload.get("selection_metadata_receipt") or payload.get("recipe_routing_receipt"):
return True
if isinstance(payload.get("project_palette"), dict) or isinstance(payload.get("project_theme"), dict):
return True
slides = payload.get("slides")

View File

@@ -675,6 +675,22 @@ class SVGlideQualityGateTest(unittest.TestCase):
self.assertEqual(missing["status"], "missing")
self.assertIn("theme_adherence", result["inputs"])
def test_quality_gate_requires_selection_reviews_for_svg_route(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
self.write_minimal_passing_project(project)
plan_path = project / "02-plan/slide_plan.json"
plan = json.loads(plan_path.read_text(encoding="utf-8"))
plan["route"] = "svglide-svg"
write_json(plan_path, plan)
result = svglide_quality_gate.run_quality_gate(project)
self.assertEqual(result["status"], "failed")
checks = {check["name"]: check for check in result["checks"]}
self.assertEqual(checks["theme-template-selection-review"]["status"], "missing")
self.assertIn("theme_template_selection_review", result["inputs"])
def test_quality_gate_fails_when_production_receipt_uses_legacy_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
import svglide_semantic_asset_matcher
SCRIPT_DIR = Path(__file__).resolve().parent
LARK_SLIDES_DIR = SCRIPT_DIR.parent
REFERENCES_DIR = LARK_SLIDES_DIR / "references"
RECIPE_REGISTRY_PATH = REFERENCES_DIR / "svglide-deck-recipe-registry.json"
STYLE_PACK_REGISTRY_PATH = REFERENCES_DIR / "svglide-style-pack-registry.json"
MATCH_LEVEL_THRESHOLDS = {
"L1": 32,
"L2": 16,
"L3": 8,
}
REQUIRED_RECIPE_METADATA_FIELDS = {"mood", "tone", "best_for", "avoid_for", "density", "formality"}
NOISE_ONLY_RE = re.compile(r"随便|高级感|好看|酷炫|没有主题|灵感合集|whatever|make it nice", re.IGNORECASE)
def load_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"expected JSON object: {path}")
return payload
def load_recipe_registry(path: Path = RECIPE_REGISTRY_PATH) -> list[dict[str, Any]]:
payload = load_json(path)
recipes = payload.get("recipes")
if not isinstance(recipes, list):
raise ValueError(f"{path} must contain recipes[]")
return [item for item in recipes if isinstance(item, dict)]
def load_style_pack_registry(path: Path = STYLE_PACK_REGISTRY_PATH) -> list[dict[str, Any]]:
payload = load_json(path)
packs = payload.get("style_packs")
if not isinstance(packs, list):
raise ValueError(f"{path} must contain style_packs[]")
return [item for item in packs if isinstance(item, dict)]
def normalize_text(value: Any) -> str:
return str(value or "").strip().lower().replace("_", " ").replace("-", " ")
def text_values(value: Any) -> list[str]:
if isinstance(value, list):
return [item for child in value for item in text_values(child)]
if isinstance(value, dict):
return [item for child in value.values() for item in text_values(child)]
text = normalize_text(value)
return [text] if text else []
def prompt_contains(prompt_norm: str, signal: str) -> bool:
signal_norm = normalize_text(signal)
if not signal_norm:
return False
if re.fullmatch(r"[a-z0-9 ]+", signal_norm):
return bool(re.search(rf"(?<![a-z0-9]){re.escape(signal_norm)}(?![a-z0-9])", prompt_norm))
return signal_norm in prompt_norm
def matching_values(prompt_norm: str, values: Any) -> list[str]:
matched: list[str] = []
for value in text_values(values):
if prompt_contains(prompt_norm, value):
matched.append(value)
return sorted(set(matched))
def registry_text(recipe: dict[str, Any]) -> list[str]:
fields = [
"keywords",
"intent_tags",
"content_shape",
"audience",
"component_slots",
"image_treatment_candidates",
]
values: list[str] = []
for field in fields:
values.extend(text_values(recipe.get(field)))
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
values.extend(text_values(metadata.get("best_for")))
return values
def prompt_specific_enough(prompt: str) -> bool:
prompt_norm = normalize_text(prompt)
if len(prompt_norm) < 6:
return False
if NOISE_ONLY_RE.search(prompt_norm) and not svglide_semantic_asset_matcher.infer_brief_signals(prompt):
return False
return True
def score_recipe(prompt: str, recipe: dict[str, Any]) -> dict[str, Any]:
prompt_norm = normalize_text(prompt)
score = 0
signals: dict[str, list[str]] = {}
keyword_matches = matching_values(prompt_norm, recipe.get("keywords"))
score += len(keyword_matches) * 8
if keyword_matches:
signals["keywords"] = keyword_matches
for field, weight in [
("intent_tags", 5),
("content_shape", 5),
("audience", 4),
("component_slots", 3),
("image_treatment_candidates", 3),
]:
matches = matching_values(prompt_norm, recipe.get(field))
if matches:
score += len(matches) * weight
signals[field] = matches
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
for field, weight in [("best_for", 5), ("mood", 2), ("tone", 2)]:
matches = matching_values(prompt_norm, metadata.get(field))
if matches:
score += len(matches) * weight
signals[f"metadata.{field}"] = matches
inferred = svglide_semantic_asset_matcher.infer_brief_signals(prompt)
inferred_values = [value for value in text_values(inferred) if value]
registry_values = set(registry_text(recipe))
inferred_matches = sorted({value for value in inferred_values if value in registry_values})
if inferred_matches:
score += len(inferred_matches) * 3
signals["semantic_asset_matcher"] = inferred_matches
avoid_matches = matching_values(prompt_norm, recipe.get("avoid_when"))
if avoid_matches:
score -= len(avoid_matches) * 12
signals["avoid_when"] = avoid_matches
return {
"recipe_id": recipe.get("recipe_id"),
"score": max(score, 0),
"signals": signals,
}
def match_level(score: int) -> str:
if score >= MATCH_LEVEL_THRESHOLDS["L1"]:
return "L1"
if score >= MATCH_LEVEL_THRESHOLDS["L2"]:
return "L2"
if score >= MATCH_LEVEL_THRESHOLDS["L3"]:
return "L3"
return "L4"
def confidence_for(score: int, level: str) -> float:
if level == "L4":
return 0.0
if level == "L3":
return round(min(0.49, 0.32 + score / 80), 2)
if level == "L2":
return round(min(0.74, 0.5 + score / 100), 2)
return round(min(0.95, 0.72 + score / 180), 2)
def missing_signals_for(prompt: str, scored: list[dict[str, Any]]) -> list[str]:
prompt_norm = normalize_text(prompt)
missing = ["intent", "content_shape", "audience"]
if not prompt_norm:
missing.append("prompt")
if scored and scored[0]["score"] > 0:
missing.append("sufficient_recipe_confidence")
if NOISE_ONLY_RE.search(prompt_norm):
missing.append("specific_subject")
return sorted(set(missing))
def style_pack_score(prompt: str, recipe: dict[str, Any], pack: dict[str, Any]) -> int:
prompt_norm = normalize_text(prompt)
score = 0
metadata = pack.get("metadata") if isinstance(pack.get("metadata"), dict) else {}
for field, weight in [("best_for", 7), ("mood", 3), ("tone", 3)]:
score += len(matching_values(prompt_norm, metadata.get(field))) * weight
if pack.get("style_pack_id") in recipe.get("style_pack_candidates", []):
score += 20
if pack.get("image_treatment_id") in recipe.get("image_treatment_candidates", []):
score += 4
avoid = matching_values(prompt_norm, metadata.get("avoid_for"))
score -= len(avoid) * 10
return score
def select_style_pack(prompt: str, recipe: dict[str, Any], packs: list[dict[str, Any]]) -> dict[str, Any]:
candidate_ids = set(recipe.get("style_pack_candidates", []))
candidates = [pack for pack in packs if pack.get("style_pack_id") in candidate_ids]
if not candidates:
candidates = packs
ranked = sorted(
candidates,
key=lambda pack: (-style_pack_score(prompt, recipe, pack), str(pack.get("style_pack_id"))),
)
return ranked[0]
def build_failed_selection(prompt: str, scored: list[dict[str, Any]]) -> dict[str, Any]:
best = scored[0] if scored else {"recipe_id": None, "score": 0, "signals": {}}
return {
"schema_version": "svglide-design-asset-selection/v1",
"status": "failed",
"action": "fail_closed",
"prompt": prompt,
"deck_recipe_selection": {
"recipe_id": best.get("recipe_id"),
"match_level": "L4",
"confidence": 0.0,
"signals": best.get("signals") or {},
"missing_signals": missing_signals_for(prompt, scored),
"candidate_recipe_ids": [str(item["recipe_id"]) for item in scored[:5] if item.get("recipe_id")],
"selection_reason": "No recipe reached the minimum confidence required to generate.",
},
}
def build_success_selection(
prompt: str,
recipe: dict[str, Any],
recipe_score: dict[str, Any],
style_pack: dict[str, Any],
scored: list[dict[str, Any]],
) -> dict[str, Any]:
level = match_level(int(recipe_score["score"]))
template_candidates = [str(item) for item in recipe.get("template_family_candidates", [])]
style_pack_id = str(style_pack["style_pack_id"])
image_treatment_id = str(style_pack["image_treatment_id"])
density_modes = [str(item) for item in recipe.get("density_modes", [])]
component_slots = [str(item) for item in recipe.get("component_slots", [])]
decoration_policy_id = str(style_pack["decoration_policy_id"])
return {
"schema_version": "svglide-design-asset-selection/v1",
"status": "passed",
"action": "generate",
"prompt": prompt,
"deck_recipe_selection": {
"recipe_id": recipe["recipe_id"],
"display_name": recipe.get("display_name"),
"match_level": level,
"confidence": confidence_for(int(recipe_score["score"]), level),
"signals": recipe_score["signals"],
"missing_signals": [],
"candidate_recipe_ids": [str(item["recipe_id"]) for item in scored[:5] if item.get("recipe_id")],
"selection_reason": "Matched recipe metadata from prompt intent, content shape, audience, and asset signals.",
},
"template_family_selection": {
"enabled": True,
"source": "beautiful-html-template-families",
"selected_template_id": template_candidates[0],
"candidate_template_ids": template_candidates,
"selection_reason": f"Recipe {recipe['recipe_id']} prefers this template family for structure; final visual skin comes from style_pack.",
},
"style_pack_selection": {
"selected_style_pack_id": style_pack_id,
"candidate_style_pack_ids": [str(item) for item in recipe.get("style_pack_candidates", [])],
"selection_reason": f"Selected {style_pack_id} because it matches the recipe tone and image treatment requirements.",
"palette_id": style_pack["palette_id"],
"typography_id": style_pack["typography_id"],
"background_system_id": style_pack["background_system_id"],
"chart_palette_id": style_pack["chart_palette_id"],
"image_treatment_id": image_treatment_id,
"decoration_policy_id": decoration_policy_id,
"component_variant_bias": style_pack["component_variant_bias"],
},
"density_mode_selection": {
"selected_density_mode": density_modes[0],
"candidate_density_modes": density_modes,
"selection_reason": "Density mode follows the matched deck recipe rather than per-page reinvention.",
},
"component_variant_selection": {
"selected_component_variants": component_slots[:6],
"candidate_component_variants": component_slots,
"selection_reason": "Component variants are constrained by recipe slots and style_pack bias.",
},
"image_treatment_selection": {
"selected_image_treatment_id": image_treatment_id,
"candidate_image_treatment_ids": [str(item) for item in recipe.get("image_treatment_candidates", [])],
"selection_reason": "Image treatment is locked at deck level; local preview and live create must consume the same metadata.",
},
"style_lock": {
"template_family_id": template_candidates[0],
"style_pack_id": style_pack_id,
"palette_id": style_pack["palette_id"],
"typography_id": style_pack["typography_id"],
"background_system_id": style_pack["background_system_id"],
"chart_palette_id": style_pack["chart_palette_id"],
"image_treatment_id": image_treatment_id,
"decoration_policy_id": decoration_policy_id,
"component_variant_bias": style_pack["component_variant_bias"],
"deck_level": True,
},
}
def validate_registry_contracts(recipes: list[dict[str, Any]], packs: list[dict[str, Any]]) -> None:
style_pack_ids = {pack.get("style_pack_id") for pack in packs}
for recipe in recipes:
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
missing = REQUIRED_RECIPE_METADATA_FIELDS - set(metadata)
if missing:
raise ValueError(f"recipe {recipe.get('recipe_id')} missing metadata fields: {sorted(missing)}")
if not set(recipe.get("style_pack_candidates", [])) & style_pack_ids:
raise ValueError(f"recipe {recipe.get('recipe_id')} has no valid style_pack_candidates")
def select_design_assets(prompt: str) -> dict[str, Any]:
recipes = load_recipe_registry()
packs = load_style_pack_registry()
validate_registry_contracts(recipes, packs)
scored = sorted(
[score_recipe(prompt, recipe) for recipe in recipes],
key=lambda item: (-int(item["score"]), str(item.get("recipe_id"))),
)
if not prompt_specific_enough(prompt) or not scored:
return build_failed_selection(prompt, scored)
best = scored[0]
level = match_level(int(best["score"]))
if level == "L4":
return build_failed_selection(prompt, scored)
recipes_by_id = {recipe["recipe_id"]: recipe for recipe in recipes}
recipe = recipes_by_id[str(best["recipe_id"])]
style_pack = select_style_pack(prompt, recipe, packs)
return build_success_selection(prompt, recipe, best, style_pack, scored)
def main() -> int:
parser = argparse.ArgumentParser(description="Select SVGlide deck recipe and composable design assets.")
parser.add_argument("--prompt", required=True)
parser.add_argument("--out", default=None)
args = parser.parse_args()
result = select_design_assets(args.prompt)
text = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
if args.out:
Path(args.out).write_text(text + "\n", encoding="utf-8")
else:
print(text)
return 0 if result.get("status") == "passed" else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_recipe_selector as selector
SCRIPT_DIR = Path(__file__).resolve().parent
REFERENCES_DIR = SCRIPT_DIR.parent / "references"
FIXTURE_DIR = SCRIPT_DIR / "fixtures" / "svglide_recipe_matching"
LEVEL_RANK = {"L1": 1, "L2": 2, "L3": 3, "L4": 4}
REQUIRED_METADATA_FIELDS = {"mood", "tone", "best_for", "avoid_for", "density", "formality"}
FORBIDDEN_BASELINES = {"svglide-baseline", "svglide baseline theme", "baseline"}
def load_json(path: Path) -> dict:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise AssertionError(f"{path} must contain a JSON object")
return payload
class SVGlideRecipeSelectorTest(unittest.TestCase):
def test_registry_contract_files_exist(self) -> None:
for name in [
"svglide-deck-recipe-registry.schema.json",
"svglide-deck-recipe-registry.json",
"svglide-style-pack-registry.schema.json",
"svglide-style-pack-registry.json",
"svglide-semantic-route-cases.schema.json",
"svglide-semantic-route-cases.json",
]:
self.assertTrue((REFERENCES_DIR / name).exists(), name)
def test_registries_have_routeable_metadata(self) -> None:
recipes = selector.load_recipe_registry()
style_packs = selector.load_style_pack_registry()
self.assertGreaterEqual(len(recipes), 10)
self.assertGreaterEqual(len(style_packs), 8)
style_pack_ids = {item["style_pack_id"] for item in style_packs}
for recipe in recipes:
with self.subTest(recipe=recipe.get("recipe_id")):
self.assertTrue(recipe.get("recipe_id"))
self.assertTrue(REQUIRED_METADATA_FIELDS.issubset(recipe.get("metadata", {})))
self.assertTrue(recipe.get("template_family_candidates"))
self.assertTrue(recipe.get("style_pack_candidates"))
self.assertTrue(recipe.get("component_slots"))
self.assertTrue(recipe.get("image_treatment_candidates"))
self.assertTrue(set(recipe["style_pack_candidates"]) & style_pack_ids)
for pack in style_packs:
with self.subTest(style_pack=pack.get("style_pack_id")):
self.assertTrue(pack.get("style_pack_id"))
self.assertTrue(REQUIRED_METADATA_FIELDS.issubset(pack.get("metadata", {})))
for field in [
"palette_id",
"typography_id",
"background_system_id",
"chart_palette_id",
"image_treatment_id",
"decoration_policy_id",
"component_variant_bias",
]:
self.assertIn(field, pack)
def test_42_golden_cases_are_all_l1_or_l2(self) -> None:
payload = load_json(FIXTURE_DIR / "cases_42.json")
cases = payload["cases"]
self.assertEqual(len(cases), 42)
results = []
for case in cases:
with self.subTest(case_id=case["case_id"]):
result = selector.select_design_assets(case["prompt"])
recipe = result["deck_recipe_selection"]
self.assertIn(recipe["recipe_id"], case["expected_recipe_ids"])
self.assertLessEqual(LEVEL_RANK[recipe["match_level"]], LEVEL_RANK[case["min_match_level"]])
self.assertIn(recipe["match_level"], {"L1", "L2"})
self.assertGreaterEqual(recipe["confidence"], 0.5)
self.assert_complete_selection(result)
results.append(recipe["match_level"])
self.assertEqual(len([level for level in results if level in {"L1", "L2"}]), 42)
def test_out_of_sample_routes_or_fails_closed_with_explanation(self) -> None:
payload = load_json(FIXTURE_DIR / "out_of_sample.json")
cases = payload["cases"]
self.assertGreaterEqual(len(cases), 9)
observed_non_l1 = False
for case in cases:
with self.subTest(case_id=case["case_id"]):
result = selector.select_design_assets(case["prompt"])
recipe = result["deck_recipe_selection"]
self.assertLessEqual(LEVEL_RANK[recipe["match_level"]], LEVEL_RANK[case["max_match_level"]])
if recipe["match_level"] != "L1":
observed_non_l1 = True
if recipe["match_level"] == "L4":
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "fail_closed")
self.assertTrue(recipe["missing_signals"])
else:
self.assertEqual(result["status"], "passed")
self.assertTrue(recipe["signals"])
self.assert_complete_selection(result)
self.assertTrue(observed_non_l1)
def test_selection_never_uses_baseline_as_successful_fallback(self) -> None:
for prompt in ["随便做一个好看的 PPT", "完全没有主题,只要高级感", ""]:
with self.subTest(prompt=prompt):
result = selector.select_design_assets(prompt)
blob = json.dumps(result, ensure_ascii=False).lower()
self.assertFalse(any(item in blob for item in FORBIDDEN_BASELINES))
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "fail_closed")
def test_style_lock_is_deck_level_and_auditable(self) -> None:
result = selector.select_design_assets("豆包 App 竞品分析,关注产品能力、用户场景和真实产品截图")
self.assertEqual(result["status"], "passed")
style_lock = result["style_lock"]
self.assertEqual(style_lock["style_pack_id"], result["style_pack_selection"]["selected_style_pack_id"])
self.assertEqual(style_lock["image_treatment_id"], result["image_treatment_selection"]["selected_image_treatment_id"])
self.assertNotEqual(style_lock["decoration_policy_id"], "random_decorations")
self.assertIn("selection_reason", result["style_pack_selection"])
self.assertIn("selection_reason", result["image_treatment_selection"])
def assert_complete_selection(self, result: dict) -> None:
for key in [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
self.assertIn(key, result)
self.assertEqual(result["template_family_selection"]["enabled"], True)
self.assertTrue(result["template_family_selection"]["selected_template_id"])
self.assertTrue(result["style_pack_selection"]["selected_style_pack_id"])
self.assertTrue(result["density_mode_selection"]["selected_density_mode"])
self.assertTrue(result["component_variant_selection"]["selected_component_variants"])
self.assertTrue(result["image_treatment_selection"]["selected_image_treatment_id"])
if __name__ == "__main__":
unittest.main()

View File

@@ -17,8 +17,19 @@ STAGE = "theme_template_selection_review"
SELECTION_PATH = Path("02-plan/theme-template-selection.json")
PALETTE_SELECTION_PATH = Path("02-plan/palette-selection.json")
PLAN_PATH = Path("02-plan/slide_plan.json")
DESIGN_SELECTION_PATH = Path("02-plan/selection-metadata.json")
RECIPE_ROUTING_RECEIPT_PATH = Path("02-plan/recipe-routing-receipt.json")
CHECK_PATH = Path("06-check/theme-template-selection-review.json")
RECEIPT_PATH = Path("receipts/theme_template_selection_review.json")
REQUIRED_DESIGN_SELECTION_KEYS = [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]
def now_iso() -> str:
@@ -58,6 +69,10 @@ def load_plan(project_root: Path) -> dict[str, Any]:
return read_json(project_root / PLAN_PATH)
def load_design_selection(project_root: Path) -> dict[str, Any]:
return read_json(project_root / DESIGN_SELECTION_PATH)
def allowed_template_ids(selection: dict[str, Any]) -> set[str]:
values = {str(selection.get("selected_template_id"))} if selection.get("selected_template_id") else set()
for item in selection.get("template_candidates", []):
@@ -217,6 +232,42 @@ def validate_plan_against_selection(plan: dict[str, Any], selection: dict[str, A
return issues
def is_svg_route_plan(plan: dict[str, Any]) -> bool:
return plan.get("route") == "svglide-svg" or plan.get("output_mode") == "svglide-svg"
def validate_design_asset_selection(plan: dict[str, Any], design_selection: dict[str, Any]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for key in REQUIRED_DESIGN_SELECTION_KEYS:
if not isinstance(design_selection.get(key), dict):
issues.append(issue("design_asset_selection_field_missing", f"selection-metadata.json must include {key}", path=key))
if issues:
return issues
if design_selection.get("status") != "passed":
issues.append(issue("design_asset_selection_not_passed", "design asset selection must be passed before SVG generation", path="status"))
recipe = design_selection["deck_recipe_selection"]
if recipe.get("match_level") == "L4":
issues.append(issue("design_asset_selection_l4", "L4 recipe selection must fail closed and cannot enter SVG generation", path="deck_recipe_selection.match_level"))
for key in ["recipe_id", "match_level", "confidence", "signals"]:
if key not in recipe:
issues.append(issue("design_asset_recipe_field_missing", f"deck_recipe_selection must include {key}", path=f"deck_recipe_selection.{key}"))
style_lock = design_selection["style_lock"]
style_pack = design_selection["style_pack_selection"]
image_treatment = design_selection["image_treatment_selection"]
if style_lock.get("deck_level") is not True:
issues.append(issue("style_lock_not_deck_level", "style_lock must be deck_level=true", path="style_lock.deck_level"))
if style_lock.get("style_pack_id") != style_pack.get("selected_style_pack_id"):
issues.append(issue("style_lock_style_pack_mismatch", "style_lock.style_pack_id must match selected_style_pack_id", path="style_lock.style_pack_id"))
if style_lock.get("image_treatment_id") != image_treatment.get("selected_image_treatment_id"):
issues.append(issue("style_lock_image_treatment_mismatch", "style_lock.image_treatment_id must match selected image treatment", path="style_lock.image_treatment_id"))
if style_lock.get("decoration_policy_id") in {"random_decorations", "decorative_noise"}:
issues.append(issue("style_lock_disallowed_decoration_policy", "random decoration policy is not allowed", path="style_lock.decoration_policy_id"))
for key in REQUIRED_DESIGN_SELECTION_KEYS:
if key in plan and plan.get(key) != design_selection.get(key):
issues.append(issue("design_asset_selection_plan_mismatch", f"slide_plan.{key} must match selection-metadata.json", path=key))
return issues
def run_review(project_root: Path) -> dict[str, Any]:
project_root = project_root.resolve()
issues: list[dict[str, Any]] = []
@@ -235,10 +286,19 @@ def run_review(project_root: Path) -> dict[str, Any]:
except (OSError, json.JSONDecodeError, ValueError) as err:
plan = {}
issues.append(issue("plan_missing", f"could not read {PLAN_PATH}: {err}", path=PLAN_PATH.as_posix()))
design_selection: dict[str, Any] = {}
design_selection_required = is_svg_route_plan(plan) or (project_root / DESIGN_SELECTION_PATH).exists()
if design_selection_required:
try:
design_selection = load_design_selection(project_root)
except (OSError, json.JSONDecodeError, ValueError) as err:
issues.append(issue("design_asset_selection_missing", f"could not read {DESIGN_SELECTION_PATH}: {err}", path=DESIGN_SELECTION_PATH.as_posix()))
if selection and palette_selection and plan:
issues.extend(selected_legacy_issues(selection, palette_selection))
issues.extend(validate_project_theme(plan, selection))
issues.extend(validate_plan_against_selection(plan, selection, palette_selection))
if design_selection:
issues.extend(validate_design_asset_selection(plan, design_selection))
return {
"schema_version": SCHEMA_VERSION,
"stage": STAGE,
@@ -248,6 +308,8 @@ def run_review(project_root: Path) -> dict[str, Any]:
"selection": SELECTION_PATH.as_posix(),
"palette_selection": PALETTE_SELECTION_PATH.as_posix(),
"plan": PLAN_PATH.as_posix(),
"design_selection": DESIGN_SELECTION_PATH.as_posix() if design_selection_required else None,
"recipe_routing_receipt": RECIPE_ROUTING_RECEIPT_PATH.as_posix() if design_selection_required else None,
},
"summary": {"error_count": len(issues)},
"issues": issues,

View File

@@ -12,6 +12,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import svglide_palette_selector
import svglide_recipe_selector
import svglide_selection_review as review
import svglide_theme_template_selector
@@ -250,6 +251,46 @@ class SelectionReviewTest(unittest.TestCase):
self.assertEqual(result["status"], "failed")
self.assertIn("selection_trace_missing", {item["code"] for item in result["issues"]})
def test_selection_review_requires_design_asset_metadata_for_svg_route(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
make_project(root)
plan = json.loads((root / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["route"] = "svglide-svg"
write_json(root / "02-plan/slide_plan.json", plan)
result = review.run_review(root)
self.assertEqual(result["status"], "failed")
self.assertIn("design_asset_selection_missing", {item["code"] for item in result["issues"]})
def test_selection_review_accepts_complete_design_asset_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
make_project(root)
metadata = svglide_recipe_selector.select_design_assets("内部业务复盘,管理层阅读,包含 KPI 和行动")
write_json(root / "02-plan/selection-metadata.json", metadata)
write_json(root / "02-plan/recipe-routing-receipt.json", metadata)
plan = json.loads((root / "02-plan/slide_plan.json").read_text(encoding="utf-8"))
plan["route"] = "svglide-svg"
plan["selection_metadata_receipt"] = "02-plan/selection-metadata.json"
plan["recipe_routing_receipt"] = "02-plan/recipe-routing-receipt.json"
for key in [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
plan[key] = metadata[key]
write_json(root / "02-plan/slide_plan.json", plan)
result = review.run_review(root)
self.assertEqual(result["status"], "passed", result["issues"])
if __name__ == "__main__":
unittest.main()

View File

@@ -733,3 +733,9 @@ def rank_assets(prompt: str, assets: list[dict[str, Any]], *, top_k: int = 3) ->
"candidate_rejection_reasons": rejection_reasons,
"candidates": scored[:top_k],
}
def select_design_asset_metadata(prompt: str) -> dict[str, Any]:
import svglide_recipe_selector
return svglide_recipe_selector.select_design_assets(prompt)

View File

@@ -985,6 +985,24 @@ class SVGlideSemanticAssetMatcherTest(unittest.TestCase):
self.assertIn("system design", signals.get("occasion", []))
self.assertIn("architecture", signals.get("content_shape", []))
def test_design_asset_metadata_selection_contains_total_plan_contract(self) -> None:
result = matcher.select_design_asset_metadata("智谱和 MiniMax 产品对比,关注真实产品截图和能力矩阵")
self.assertEqual(result["status"], "passed")
self.assertEqual(result["deck_recipe_selection"]["recipe_id"], "consumer_ai_product_analysis")
for key in [
"deck_recipe_selection",
"template_family_selection",
"style_pack_selection",
"density_mode_selection",
"component_variant_selection",
"image_treatment_selection",
"style_lock",
]:
self.assertIn(key, result)
self.assertEqual(result["style_lock"]["style_pack_id"], result["style_pack_selection"]["selected_style_pack_id"])
self.assertEqual(result["image_treatment_selection"]["selected_image_treatment_id"], "real_product_screenshot")
def make_case_test(case: SemanticCase):
def test(self: SVGlideSemanticAssetMatcherTest) -> None: