diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 7ecf1dc8d..9f60e259d 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -25,6 +25,7 @@ metadata: | 使用语义图标 | 先检索 IconPark,再写 `` | `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 routing:recipe -> 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 gate,HTML 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 [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/ --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/ --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 私有清单中的策略正文。 diff --git a/skills/lark-slides/references/svg-private-manifest.json b/skills/lark-slides/references/svg-private-manifest.json index dac6efb6b..ca2b3154d 100644 --- a/skills/lark-slides/references/svg-private-manifest.json +++ b/skills/lark-slides/references/svg-private-manifest.json @@ -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", diff --git a/skills/lark-slides/references/svglide-deck-recipe-registry.json b/skills/lark-slides/references/svglide-deck-recipe-registry.json new file mode 100644 index 000000000..894c757c7 --- /dev/null +++ b/skills/lark-slides/references/svglide-deck-recipe-registry.json @@ -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": ["投委会", "安全事故"] + } + ] +} diff --git a/skills/lark-slides/references/svglide-deck-recipe-registry.schema.json b/skills/lark-slides/references/svglide-deck-recipe-registry.schema.json new file mode 100644 index 000000000..1ff30749b --- /dev/null +++ b/skills/lark-slides/references/svglide-deck-recipe-registry.schema.json @@ -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 +} diff --git a/skills/lark-slides/references/svglide-design-asset-routing.md b/skills/lark-slides/references/svglide-design-asset-routing.md new file mode 100644 index 000000000..2834e90bd --- /dev/null +++ b/skills/lark-slides/references/svglide-design-asset-routing.md @@ -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 +``` diff --git a/skills/lark-slides/references/svglide-diversity-gate.schema.json b/skills/lark-slides/references/svglide-diversity-gate.schema.json new file mode 100644 index 000000000..0d7241415 --- /dev/null +++ b/skills/lark-slides/references/svglide-diversity-gate.schema.json @@ -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 +} diff --git a/skills/lark-slides/references/svglide-plan.schema.json b/skills/lark-slides/references/svglide-plan.schema.json index 7a66ff556..6b6aec9ea 100644 --- a/skills/lark-slides/references/svglide-plan.schema.json +++ b/skills/lark-slides/references/svglide-plan.schema.json @@ -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": { diff --git a/skills/lark-slides/references/svglide-semantic-route-cases.json b/skills/lark-slides/references/svglide-semantic-route-cases.json new file mode 100644 index 000000000..40d854e86 --- /dev/null +++ b/skills/lark-slides/references/svglide-semantic-route-cases.json @@ -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" + } +} diff --git a/skills/lark-slides/references/svglide-semantic-route-cases.schema.json b/skills/lark-slides/references/svglide-semantic-route-cases.schema.json new file mode 100644 index 000000000..6eb531cd4 --- /dev/null +++ b/skills/lark-slides/references/svglide-semantic-route-cases.schema.json @@ -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 +} diff --git a/skills/lark-slides/references/svglide-style-pack-registry.json b/skills/lark-slides/references/svglide-style-pack-registry.json new file mode 100644 index 000000000..f325a81cf --- /dev/null +++ b/skills/lark-slides/references/svglide-style-pack-registry.json @@ -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" + } + } + ] +} diff --git a/skills/lark-slides/references/svglide-style-pack-registry.schema.json b/skills/lark-slides/references/svglide-style-pack-registry.schema.json new file mode 100644 index 000000000..31202be0a --- /dev/null +++ b/skills/lark-slides/references/svglide-style-pack-registry.schema.json @@ -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 +} diff --git a/skills/lark-slides/references/svglide-svg-private.rules.json b/skills/lark-slides/references/svglide-svg-private.rules.json index b67b30e4f..593376993 100644 --- a/skills/lark-slides/references/svglide-svg-private.rules.json +++ b/skills/lark-slides/references/svglide-svg-private.rules.json @@ -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", diff --git a/skills/lark-slides/scripts/beautiful_template_matcher.py b/skills/lark-slides/scripts/beautiful_template_matcher.py index f2a9e6388..40a7b1da7 100644 --- a/skills/lark-slides/scripts/beautiful_template_matcher.py +++ b/skills/lark-slides/scripts/beautiful_template_matcher.py @@ -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: diff --git a/skills/lark-slides/scripts/beautiful_template_matcher_test.py b/skills/lark-slides/scripts/beautiful_template_matcher_test.py index d211420de..96ab814a0 100644 --- a/skills/lark-slides/scripts/beautiful_template_matcher_test.py +++ b/skills/lark-slides/scripts/beautiful_template_matcher_test.py @@ -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 = [ diff --git a/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/cases_42.json b/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/cases_42.json new file mode 100644 index 000000000..2727e2fb6 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/cases_42.json @@ -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" + } + ] +} diff --git a/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/out_of_sample.json b/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/out_of_sample.json new file mode 100644 index 000000000..7be8dc304 --- /dev/null +++ b/skills/lark-slides/scripts/fixtures/svglide_recipe_matching/out_of_sample.json @@ -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" + } + ] +} diff --git a/skills/lark-slides/scripts/svg_preflight.py b/skills/lark-slides/scripts/svg_preflight.py index 6a1cb203b..0bfd12399 100644 --- a/skills/lark-slides/scripts/svg_preflight.py +++ b/skills/lark-slides/scripts/svg_preflight.py @@ -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 = "") -> 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) diff --git a/skills/lark-slides/scripts/svg_preflight_test.py b/skills/lark-slides/scripts/svg_preflight_test.py index 2c17cc72d..2969a0947 100644 --- a/skills/lark-slides/scripts/svg_preflight_test.py +++ b/skills/lark-slides/scripts/svg_preflight_test.py @@ -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) diff --git a/skills/lark-slides/scripts/svglide_diversity_gate.py b/skills/lark-slides/scripts/svglide_diversity_gate.py new file mode 100644 index 000000000..1e343975e --- /dev/null +++ b/skills/lark-slides/scripts/svglide_diversity_gate.py @@ -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()) diff --git a/skills/lark-slides/scripts/svglide_diversity_gate_test.py b/skills/lark-slides/scripts/svglide_diversity_gate_test.py new file mode 100644 index 000000000..e94f668e6 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_diversity_gate_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_palette_review.py b/skills/lark-slides/scripts/svglide_palette_review.py index d2681c36a..5f8196366 100644 --- a/skills/lark-slides/scripts/svglide_palette_review.py +++ b/skills/lark-slides/scripts/svglide_palette_review.py @@ -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")) diff --git a/skills/lark-slides/scripts/svglide_palette_selector.py b/skills/lark-slides/scripts/svglide_palette_selector.py index d5f1012f0..e449083e1 100644 --- a/skills/lark-slides/scripts/svglide_palette_selector.py +++ b/skills/lark-slides/scripts/svglide_palette_selector.py @@ -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", []) diff --git a/skills/lark-slides/scripts/svglide_palette_selector_test.py b/skills/lark-slides/scripts/svglide_palette_selector_test.py index a8e088de6..9ef27288a 100644 --- a/skills/lark-slides/scripts/svglide_palette_selector_test.py +++ b/skills/lark-slides/scripts/svglide_palette_selector_test.py @@ -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) diff --git a/skills/lark-slides/scripts/svglide_project_runner.py b/skills/lark-slides/scripts/svglide_project_runner.py index 0fd40fd1a..222bda236 100644 --- a/skills/lark-slides/scripts/svglide_project_runner.py +++ b/skills/lark-slides/scripts/svglide_project_runner.py @@ -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]: diff --git a/skills/lark-slides/scripts/svglide_project_runner_test.py b/skills/lark-slides/scripts/svglide_project_runner_test.py index 70f1ab154..511cdaeea 100644 --- a/skills/lark-slides/scripts/svglide_project_runner_test.py +++ b/skills/lark-slides/scripts/svglide_project_runner_test.py @@ -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) diff --git a/skills/lark-slides/scripts/svglide_quality_gate.py b/skills/lark-slides/scripts/svglide_quality_gate.py index 23c85bc15..bfa22d4cf 100644 --- a/skills/lark-slides/scripts/svglide_quality_gate.py +++ b/skills/lark-slides/scripts/svglide_quality_gate.py @@ -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") diff --git a/skills/lark-slides/scripts/svglide_quality_gate_test.py b/skills/lark-slides/scripts/svglide_quality_gate_test.py index 42a9761cf..f7cd6679e 100644 --- a/skills/lark-slides/scripts/svglide_quality_gate_test.py +++ b/skills/lark-slides/scripts/svglide_quality_gate_test.py @@ -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) diff --git a/skills/lark-slides/scripts/svglide_recipe_selector.py b/skills/lark-slides/scripts/svglide_recipe_selector.py new file mode 100644 index 000000000..1ce264b46 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_recipe_selector.py @@ -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"(? 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()) diff --git a/skills/lark-slides/scripts/svglide_recipe_selector_test.py b/skills/lark-slides/scripts/svglide_recipe_selector_test.py new file mode 100644 index 000000000..51ea853b8 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_recipe_selector_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_selection_review.py b/skills/lark-slides/scripts/svglide_selection_review.py index d43af5b84..c673afed9 100644 --- a/skills/lark-slides/scripts/svglide_selection_review.py +++ b/skills/lark-slides/scripts/svglide_selection_review.py @@ -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, diff --git a/skills/lark-slides/scripts/svglide_selection_review_test.py b/skills/lark-slides/scripts/svglide_selection_review_test.py index f26dd6ec3..f2ec8f43b 100644 --- a/skills/lark-slides/scripts/svglide_selection_review_test.py +++ b/skills/lark-slides/scripts/svglide_selection_review_test.py @@ -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() diff --git a/skills/lark-slides/scripts/svglide_semantic_asset_matcher.py b/skills/lark-slides/scripts/svglide_semantic_asset_matcher.py index a8bef551e..28ebe3e95 100644 --- a/skills/lark-slides/scripts/svglide_semantic_asset_matcher.py +++ b/skills/lark-slides/scripts/svglide_semantic_asset_matcher.py @@ -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) diff --git a/skills/lark-slides/scripts/svglide_semantic_asset_matcher_test.py b/skills/lark-slides/scripts/svglide_semantic_asset_matcher_test.py index 11494ea4c..6de6ce9b1 100644 --- a/skills/lark-slides/scripts/svglide_semantic_asset_matcher_test.py +++ b/skills/lark-slides/scripts/svglide_semantic_asset_matcher_test.py @@ -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: