mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
feat: 落地 SVGlide 设计资产组合路由
- 新增 deck recipe/style pack/semantic route registry 与 42 条路由用例,支持未知主题 fail-closed。 - 将 select_style 接入 recipe -> template family -> style_pack -> palette/component/image treatment,并回填 selection metadata 与 style_lock。 - 新增 diversity_gate 并接入 runner/quality_gate,阻断 style drift 和 template/style/layout/component 组合复用。 - 更新 SVG route 文档、schema 与私有索引,并补充 selector/review/preflight/quality/runner 测试。
This commit is contained in:
@@ -25,6 +25,7 @@ metadata:
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| SVG route 需要主题/模板家族 | 先用 beautiful template family matcher 选择 deck-level family、variants、components、asset strategy | `beautiful_template_matcher.py`、`beautiful-html-template-families.json`、`component-registry.json`、`asset-strategy-registry.json` |
|
||||
| SVG route 需要从主题提示词选择设计资产 | 先运行 design asset 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 <resource> <method> [flags] # 调用 API
|
||||
|
||||
## SVG Route
|
||||
|
||||
`slides +create-svg` 只作为命令入口出现在顶层。出现 SVG / SVGlide / `slides +create-svg` 需求时,先读取 [`svglide-route-admission.md`](references/svglide-route-admission.md),命中后再按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载 SVG 私有协议、规划、验证和排障文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅作为兼容索引保留。新建或大幅改写 SVG deck 时,优先使用 [`svglide_project_runner.py`](scripts/svglide_project_runner.py) 管理控制面:`init` 建目录,`source` 归一化 `source/evidence.json` 并写入 source receipt,`plan` 由 agent 或外部产物阶段完成并写入对应文件,`strategy_review` 先锁定语言、受众、页型、章节、内容厚度和 `visual_identity`,`confirm_plan` 仅作为兼容/人工审批面保留,不是默认必经节点;runner 再负责 `assets -> generate_svg -> contract_compile -> prepare -> preview -> preflight -> preview_lint -> aesthetic_review -> chart_verify -> semantic_review -> runtime_review -> visual_distinctness_review -> quality_gate`。本地内容验收使用 `run .lark-slides/plan/<deck-id> --profile preview_only` 默认停在 `quality_gate`。`assets` 负责资产契约和 token/local-file 审计,`generate_svg` 在 `artboard_satori` 模式只产 `04-artboard/raw` raw visual/semantic artifacts,`contract_compile` 才注入 SVGlide/Slides 私有协议并写入 `04-svg/page-###.svg`、contract report/manifest 和资产注入摘要,`prepare` 只消费 contract manifest 校验过的新鲜 canonical SVG。`chart_verify` 只在页面声明 required/exact chart contract 时强制,`semantic_review` 负责 `semantic-review.json` 和 `text-inventory.json`,`runtime_review` 负责 renderer/layout 多样性,`visual_distinctness_review` 负责阻断不同主题复用同一套 palette、cover treatment 和 renderer/layout 序列。live create 前必须有新鲜的 `quality_gate.status=passed`、`dry-run.json` 和 `ppe-proof.json`,live create 后必须进入 readback stage。XML route 不得读取 SVG 私有清单中的策略正文。
|
||||
`slides +create-svg` 只作为命令入口出现在顶层。出现 SVG / SVGlide / `slides +create-svg` 需求时,先读取 [`svglide-route-admission.md`](references/svglide-route-admission.md),命中后再按 [`svglide-svg-private.rules.json`](references/svglide-svg-private.rules.json) 加载 SVG 私有协议、规划、验证和排障文档,旧 [`svg-private-manifest.json`](references/svg-private-manifest.json) 仅作为兼容索引保留。新建或大幅改写 SVG deck 时,优先使用 [`svglide_project_runner.py`](scripts/svglide_project_runner.py) 管理控制面:`init` 建目录,`source` 归一化 `source/evidence.json` 并写入 source receipt,`select_style` 执行 recipe/style_pack/palette/template routing,`plan` 将 selection receipts 回填到 `slide_plan.json`,`strategy_review` 先锁定语言、受众、页型、章节、内容厚度和 `visual_identity`,`confirm_plan` 仅作为兼容/人工审批面保留,不是默认必经节点;runner 再负责 `assets -> generate_svg -> contract_compile -> prepare -> preview -> preflight -> preview_lint -> aesthetic_review -> chart_verify -> semantic_review -> runtime_review -> visual_distinctness_review -> diversity_gate -> theme_adherence -> quality_gate`。本地内容验收使用 `run .lark-slides/plan/<deck-id> --profile preview_only` 默认停在 `quality_gate`。`assets` 负责资产契约和 token/local-file 审计,`generate_svg` 在 `artboard_satori` 模式只产 `04-artboard/raw` raw visual/semantic artifacts,`contract_compile` 才注入 SVGlide/Slides 私有协议并写入 `04-svg/page-###.svg`、contract report/manifest 和资产注入摘要,`prepare` 只消费 contract manifest 校验过的新鲜 canonical SVG。`chart_verify` 只在页面声明 required/exact chart contract 时强制,`semantic_review` 负责 `semantic-review.json` 和 `text-inventory.json`,`runtime_review` 负责 renderer/layout 多样性,`visual_distinctness_review` 负责阻断不同主题复用同一套 palette、cover treatment 和 renderer/layout 序列,`diversity_gate` 负责阻断 template/style/layout/component 组合过度复用和 style lock 漂移。live create 前必须有新鲜的 `quality_gate.status=passed`、`dry-run.json` 和 `ppe-proof.json`,live create 后必须进入 readback stage。XML route 不得读取 SVG 私有清单中的策略正文。
|
||||
|
||||
@@ -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",
|
||||
|
||||
291
skills/lark-slides/references/svglide-deck-recipe-registry.json
Normal file
291
skills/lark-slides/references/svglide-deck-recipe-registry.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"version": "svglide-deck-recipe-registry/v1",
|
||||
"recipes": [
|
||||
{
|
||||
"recipe_id": "internal_business_review",
|
||||
"display_name": "内部业务复盘",
|
||||
"keywords": ["内部", "复盘", "经营", "业务", "年度", "季度", "管理层", "指标", "KPI", "看板", "数据", "趋势", "问题", "原因", "行动", "owner", "增长", "渠道", "留存", "转化"],
|
||||
"intent_tags": ["复盘", "经营分析", "业务汇报"],
|
||||
"content_shape": ["数据-洞察-决策", "问题-原因-动作", "KPI 看板"],
|
||||
"audience": ["内部管理层", "业务团队", "经营负责人"],
|
||||
"metadata": {
|
||||
"mood": ["professional", "trustworthy"],
|
||||
"tone": ["analytical", "controlled"],
|
||||
"best_for": ["内部复盘", "经营分析", "业务汇报"],
|
||||
"avoid_for": ["儿童教育", "纯文化叙事"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["blue-professional", "signal", "emerald-editorial", "raw-grid"],
|
||||
"style_pack_candidates": ["corporate_blue_data", "teal_amber_growth"],
|
||||
"density_modes": ["data-heavy", "executive-summary"],
|
||||
"component_slots": ["kpi_strip", "metric_cards", "comparison_matrix", "timeline", "risk_matrix", "action_list"],
|
||||
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
|
||||
"avoid_when": ["小吃", "旅行", "儿童故事"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "consumer_ai_product_analysis",
|
||||
"display_name": "AI 产品与 App 分析",
|
||||
"keywords": ["AI", "App", "应用", "豆包", "Doubao", "智谱", "MiniMax", "Kimi", "通义", "产品", "竞品", "对比", "能力", "用户场景", "截图", "模型", "智能体"],
|
||||
"intent_tags": ["产品分析", "竞品分析", "AI 产品"],
|
||||
"content_shape": ["能力矩阵", "场景拆解", "产品截图证据"],
|
||||
"audience": ["产品团队", "业务团队", "管理层"],
|
||||
"metadata": {
|
||||
"mood": ["technical", "modern"],
|
||||
"tone": ["comparative", "analytical"],
|
||||
"best_for": ["AI 产品", "App 竞品", "公司对比"],
|
||||
"avoid_for": ["传统美食", "儿童课程"],
|
||||
"density": "medium-high",
|
||||
"formality": "medium-high"
|
||||
},
|
||||
"template_family_candidates": ["blue-professional", "raw-grid", "neo-grid-bold", "signal"],
|
||||
"style_pack_candidates": ["product_ai_indigo_cyan", "teal_amber_growth"],
|
||||
"density_modes": ["product-showcase", "data-heavy"],
|
||||
"component_slots": ["product_screenshot_panel", "comparison_matrix", "metric_cards", "timeline", "positioning_map"],
|
||||
"image_treatment_candidates": ["real_product_screenshot", "online_evidence_image"],
|
||||
"avoid_when": ["纯学术论文", "小吃菜单"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "food_culture_guide",
|
||||
"display_name": "城市小吃与餐饮文化介绍",
|
||||
"keywords": ["小吃", "美食", "餐饮", "茶餐厅", "早茶", "香港", "粤菜", "点心", "街头", "菜单", "指南", "品类", "旅行", "推荐", "味道", "城市烟火"],
|
||||
"intent_tags": ["文化介绍", "美食指南", "城市旅行"],
|
||||
"content_shape": ["品类地图", "图文故事", "推荐路线"],
|
||||
"audience": ["普通读者", "游客", "文化爱好者"],
|
||||
"metadata": {
|
||||
"mood": ["warm", "editorial"],
|
||||
"tone": ["narrative", "inviting"],
|
||||
"best_for": ["香港小吃", "城市美食", "餐饮文化"],
|
||||
"avoid_for": ["经营看板", "风险审计"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["long-table", "playful", "coral", "grove"],
|
||||
"style_pack_candidates": ["food_culture_warm_editorial", "culture_editorial_jade_magenta"],
|
||||
"density_modes": ["editorial-guide", "image-led"],
|
||||
"component_slots": ["image_story", "menu_grid", "place_cards", "timeline", "quote_panel"],
|
||||
"image_treatment_candidates": ["online_evidence_image", "editorial_photo"],
|
||||
"avoid_when": ["董事会", "KPI", "融资"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "culture_heritage_intro",
|
||||
"display_name": "地域文化与语言文化介绍",
|
||||
"keywords": ["文化", "粤语", "岭南", "香港", "广东", "语言", "历史", "传统", "流行文化", "影视", "音乐", "城市", "非遗"],
|
||||
"intent_tags": ["文化科普", "地域介绍", "语言文化"],
|
||||
"content_shape": ["历史脉络", "文化符号", "当代表达"],
|
||||
"audience": ["普通读者", "学生", "文化爱好者"],
|
||||
"metadata": {
|
||||
"mood": ["cultural", "reflective"],
|
||||
"tone": ["narrative", "magazine"],
|
||||
"best_for": ["粤语文化", "地域文化", "城市文化"],
|
||||
"avoid_for": ["经营复盘", "技术架构"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["editorial-tri-tone", "soft-editorial", "biennale-yellow", "stencil-tablet"],
|
||||
"style_pack_candidates": ["culture_editorial_jade_magenta", "food_culture_warm_editorial"],
|
||||
"density_modes": ["editorial-story", "culture-brief"],
|
||||
"component_slots": ["timeline", "quote_panel", "image_story", "comparison_cards", "fact_cards"],
|
||||
"image_treatment_candidates": ["editorial_photo", "online_evidence_image"],
|
||||
"avoid_when": ["KPI", "投委会", "事故"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "event_expo_report",
|
||||
"display_name": "展会与活动报告",
|
||||
"keywords": ["ChinaJoy", "CJ", "展会", "活动", "游戏展", "发布会", "大会", "现场", "展台", "观众", "厂商", "趋势", "cosplay", "电竞", "互动体验"],
|
||||
"intent_tags": ["活动介绍", "展会复盘", "行业观察"],
|
||||
"content_shape": ["现场图景", "参展方地图", "趋势洞察"],
|
||||
"audience": ["市场团队", "行业读者", "管理层"],
|
||||
"metadata": {
|
||||
"mood": ["energetic", "modern"],
|
||||
"tone": ["editorial", "reporting"],
|
||||
"best_for": ["ChinaJoy", "展会报告", "活动复盘"],
|
||||
"avoid_for": ["审计汇报", "学术论文"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["neo-grid-bold", "block-frame", "creative-mode", "studio"],
|
||||
"style_pack_candidates": ["event_neon_cyan_magenta", "product_ai_indigo_cyan"],
|
||||
"density_modes": ["media-showcase", "event-report"],
|
||||
"component_slots": ["hero_photo", "event_map", "trend_cards", "timeline", "quote_panel"],
|
||||
"image_treatment_candidates": ["online_event_photo_first", "online_evidence_image"],
|
||||
"avoid_when": ["董事会", "合规", "事故"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "market_investment_analysis",
|
||||
"display_name": "市场与投资分析",
|
||||
"keywords": ["市场", "投资", "行业", "机会", "格局", "供应链", "新能源", "投委会", "玩家", "规模", "竞争", "风险", "趋势", "数据趋势", "城市治理", "政策", "公共治理", "商业化"],
|
||||
"intent_tags": ["市场分析", "投资分析", "行业研究"],
|
||||
"content_shape": ["市场地图", "玩家分层", "机会风险"],
|
||||
"audience": ["投资人", "投委会", "管理层"],
|
||||
"metadata": {
|
||||
"mood": ["professional", "sharp"],
|
||||
"tone": ["analytical", "crisp"],
|
||||
"best_for": ["投资分析", "市场格局", "行业研究"],
|
||||
"avoid_for": ["美食文化", "儿童培训"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["signal", "cartesian", "blue-professional", "raw-grid"],
|
||||
"style_pack_candidates": ["investor_navy_gold", "corporate_blue_data"],
|
||||
"density_modes": ["data-heavy", "executive-summary"],
|
||||
"component_slots": ["market_map", "metric_cards", "comparison_matrix", "risk_matrix", "timeline"],
|
||||
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
|
||||
"avoid_when": ["小吃", "儿童", "活动海报"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "fundraising_pitch",
|
||||
"display_name": "融资 BP 与路演",
|
||||
"keywords": ["融资", "BP", "路演", "投资人", "pitch", "商业模式", "收入", "增长", "市场规模", "traction", "团队", "估值", "轮次"],
|
||||
"intent_tags": ["融资路演", "商业计划", "投资人材料"],
|
||||
"content_shape": ["问题-方案", "市场-产品-商业化", "里程碑"],
|
||||
"audience": ["投资人", "创始人", "董事会"],
|
||||
"metadata": {
|
||||
"mood": ["confident", "professional"],
|
||||
"tone": ["crisp", "polished"],
|
||||
"best_for": ["融资 BP", "投资人路演", "商业计划"],
|
||||
"avoid_for": ["文化科普", "小吃介绍"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["block-frame", "signal", "blue-professional", "neo-grid-bold"],
|
||||
"style_pack_candidates": ["investor_navy_gold", "teal_amber_growth"],
|
||||
"density_modes": ["executive-summary", "pitch-story"],
|
||||
"component_slots": ["market_map", "metric_cards", "traction_chart", "timeline", "team_cards"],
|
||||
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
|
||||
"avoid_when": ["小吃", "传统文化"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "training_courseware",
|
||||
"display_name": "培训课件与课程讲解",
|
||||
"keywords": ["培训", "课件", "课程", "教学", "医疗", "新人", "onboarding", "步骤", "练习", "案例", "学习", "知识点", "考核"],
|
||||
"intent_tags": ["培训课件", "课程讲解", "学习材料"],
|
||||
"content_shape": ["目标-步骤-练习", "案例-知识点-总结"],
|
||||
"audience": ["学员", "新人", "一线团队"],
|
||||
"metadata": {
|
||||
"mood": ["approachable", "practical"],
|
||||
"tone": ["instructional", "clear"],
|
||||
"best_for": ["培训课件", "医疗培训", "新人 onboarding"],
|
||||
"avoid_for": ["融资 BP", "投委会"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["daisy-days", "playful", "scatterbrain", "vellum"],
|
||||
"style_pack_candidates": ["education_sky_green", "workshop_playful_paper"],
|
||||
"density_modes": ["lesson", "workshop"],
|
||||
"component_slots": ["lesson_steps", "checklist", "exercise_cards", "case_cards", "summary_cards"],
|
||||
"image_treatment_candidates": ["online_evidence_image", "structured_fallback"],
|
||||
"avoid_when": ["投资人", "董事会", "事故"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "technical_architecture_review",
|
||||
"display_name": "技术架构与系统方案",
|
||||
"keywords": ["技术", "架构", "系统", "链路", "模块", "服务", "调用", "依赖", "SLA", "性能", "稳定性", "方案", "设计", "数据流"],
|
||||
"intent_tags": ["技术方案", "系统架构", "工程说明"],
|
||||
"content_shape": ["模块关系", "流程链路", "风险与演进"],
|
||||
"audience": ["研发团队", "技术负责人", "架构委员会"],
|
||||
"metadata": {
|
||||
"mood": ["technical", "focused"],
|
||||
"tone": ["structured", "precise"],
|
||||
"best_for": ["技术架构", "系统设计", "工程方案"],
|
||||
"avoid_for": ["美食指南", "文化故事"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["raw-grid", "cartesian", "signal", "blue-professional"],
|
||||
"style_pack_candidates": ["architecture_mono_cyan", "corporate_blue_data"],
|
||||
"density_modes": ["diagram-heavy", "data-heavy"],
|
||||
"component_slots": ["architecture_diagram", "dependency_map", "sequence_flow", "risk_matrix", "timeline"],
|
||||
"image_treatment_candidates": ["diagram_first", "chart_first"],
|
||||
"avoid_when": ["小吃", "儿童", "文化旅游"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "security_risk_diagnostic",
|
||||
"display_name": "安全风险与事故诊断",
|
||||
"keywords": ["安全", "风险", "事故", "故障", "合规", "审计", "根因", "影响范围", "整改", "治理", "漏洞", "控制项", "复盘"],
|
||||
"intent_tags": ["风险诊断", "事故复盘", "安全治理"],
|
||||
"content_shape": ["风险矩阵", "时间线", "整改动作"],
|
||||
"audience": ["安全团队", "管理层", "审计团队"],
|
||||
"metadata": {
|
||||
"mood": ["serious", "controlled"],
|
||||
"tone": ["diagnostic", "analytical"],
|
||||
"best_for": ["安全方案", "事故复盘", "合规审计"],
|
||||
"avoid_for": ["品牌发布", "美食介绍"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["cartesian", "signal", "blue-professional", "raw-grid"],
|
||||
"style_pack_candidates": ["graphite_red_risk", "corporate_blue_data"],
|
||||
"density_modes": ["risk-diagnostic", "data-heavy"],
|
||||
"component_slots": ["risk_matrix", "timeline", "control_table", "action_list", "impact_map"],
|
||||
"image_treatment_candidates": ["diagram_first", "chart_first"],
|
||||
"avoid_when": ["小吃", "儿童课程", "艺术展"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "research_policy_brief",
|
||||
"display_name": "研究报告与政策简报",
|
||||
"keywords": ["研究", "报告", "政策", "白皮书", "学术", "论文", "方法", "样本", "发现", "结论", "证据", "实验", "引用"],
|
||||
"intent_tags": ["研究报告", "政策简报", "学术汇报"],
|
||||
"content_shape": ["背景-方法-发现", "证据-结论-建议"],
|
||||
"audience": ["研究团队", "政策团队", "专业读者"],
|
||||
"metadata": {
|
||||
"mood": ["scholarly", "trustworthy"],
|
||||
"tone": ["structured", "analytical"],
|
||||
"best_for": ["研究报告", "政策简报", "学术汇报"],
|
||||
"avoid_for": ["潮流发布", "活动海报"],
|
||||
"density": "high",
|
||||
"formality": "high"
|
||||
},
|
||||
"template_family_candidates": ["vellum", "cartesian", "signal", "monochrome"],
|
||||
"style_pack_candidates": ["scholarly_vellum_ink", "corporate_blue_data"],
|
||||
"density_modes": ["report", "data-heavy"],
|
||||
"component_slots": ["evidence_table", "method_cards", "findings_grid", "quote_panel", "chart_panel"],
|
||||
"image_treatment_candidates": ["chart_first", "online_evidence_image"],
|
||||
"avoid_when": ["小吃", "夜店", "游戏展"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "brand_campaign_launch",
|
||||
"display_name": "品牌发布与营销方案",
|
||||
"keywords": ["品牌", "发布", "营销", "campaign", "传播", "主张", "新品", "人群", "渠道", "创意", "海报", "视觉", "故事"],
|
||||
"intent_tags": ["品牌发布", "营销方案", "传播策略"],
|
||||
"content_shape": ["人群洞察", "核心主张", "传播节奏"],
|
||||
"audience": ["市场团队", "品牌团队", "管理层"],
|
||||
"metadata": {
|
||||
"mood": ["confident", "modern"],
|
||||
"tone": ["bold", "editorial"],
|
||||
"best_for": ["品牌发布", "营销方案", "新品上市"],
|
||||
"avoid_for": ["安全审计", "学术报告"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["neo-grid-bold", "bold-poster", "coral", "block-frame"],
|
||||
"style_pack_candidates": ["event_neon_cyan_magenta", "teal_amber_growth"],
|
||||
"density_modes": ["campaign", "media-showcase"],
|
||||
"component_slots": ["hero_visual", "audience_cards", "timeline", "channel_map", "metric_cards"],
|
||||
"image_treatment_candidates": ["online_evidence_image", "editorial_photo"],
|
||||
"avoid_when": ["董事会", "合规", "事故"]
|
||||
},
|
||||
{
|
||||
"recipe_id": "workshop_playbook",
|
||||
"display_name": "工作坊与共创手册",
|
||||
"keywords": ["工作坊", "workshop", "共创", "头脑风暴", "议程", "练习", "分组", "产出", "下一步", "复盘", "协作"],
|
||||
"intent_tags": ["工作坊", "共创", "行动手册"],
|
||||
"content_shape": ["议程-练习-产出", "问题-共创-行动"],
|
||||
"audience": ["参与者", "项目团队", "培训师"],
|
||||
"metadata": {
|
||||
"mood": ["approachable", "collaborative"],
|
||||
"tone": ["facilitative", "clear"],
|
||||
"best_for": ["工作坊", "共创", "培训演练"],
|
||||
"avoid_for": ["融资 BP", "审计汇报"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
},
|
||||
"template_family_candidates": ["scatterbrain", "daisy-days", "pin-and-paper", "playful"],
|
||||
"style_pack_candidates": ["workshop_playful_paper", "education_sky_green"],
|
||||
"density_modes": ["workshop", "lesson"],
|
||||
"component_slots": ["agenda", "sticky_notes", "exercise_cards", "action_list", "reflection_cards"],
|
||||
"image_treatment_candidates": ["structured_fallback", "online_evidence_image"],
|
||||
"avoid_when": ["投委会", "安全事故"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
233
skills/lark-slides/references/svglide-style-pack-registry.json
Normal file
233
skills/lark-slides/references/svglide-style-pack-registry.json
Normal file
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"version": "svglide-style-pack-registry/v1",
|
||||
"style_packs": [
|
||||
{
|
||||
"style_pack_id": "corporate_blue_data",
|
||||
"display_name": "Corporate Blue Data",
|
||||
"palette_id": "corporate_blue",
|
||||
"typography_id": "system_sans_executive",
|
||||
"background_system_id": "clean_panel_grid",
|
||||
"chart_palette_id": "business_data_blue",
|
||||
"image_treatment_id": "chart_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["kpi_strip", "metric_cards", "comparison_matrix", "risk_matrix"],
|
||||
"metadata": {
|
||||
"mood": ["professional", "trustworthy", "focused"],
|
||||
"tone": ["analytical", "controlled", "clean"],
|
||||
"best_for": ["内部复盘", "经营分析", "管理层汇报", "数据看板"],
|
||||
"avoid_for": ["儿童教育", "潮流活动", "美食文化"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "product_ai_indigo_cyan",
|
||||
"display_name": "Product AI Indigo Cyan",
|
||||
"palette_id": "indigo_cyan",
|
||||
"typography_id": "tech_editorial",
|
||||
"background_system_id": "clean_panel_grid",
|
||||
"chart_palette_id": "ai_product_chart",
|
||||
"image_treatment_id": "real_product_screenshot",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["product_screenshot_panel", "comparison_matrix", "metric_cards", "timeline"],
|
||||
"metadata": {
|
||||
"mood": ["technical", "confident", "modern"],
|
||||
"tone": ["analytical", "comparative", "precise"],
|
||||
"best_for": ["AI 产品", "App 分析", "公司对比", "竞品分析"],
|
||||
"avoid_for": ["传统文化", "小吃菜单", "儿童课程"],
|
||||
"density": "medium-high",
|
||||
"formality": "medium-high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "teal_amber_growth",
|
||||
"display_name": "Teal Amber Growth",
|
||||
"palette_id": "teal_amber",
|
||||
"typography_id": "system_sans_product",
|
||||
"background_system_id": "soft_metric_panels",
|
||||
"chart_palette_id": "growth_teal_amber",
|
||||
"image_treatment_id": "online_evidence_image",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["growth_funnel", "metric_cards", "roadmap", "case_cards"],
|
||||
"metadata": {
|
||||
"mood": ["optimistic", "focused", "professional"],
|
||||
"tone": ["pragmatic", "analytical", "clear"],
|
||||
"best_for": ["增长方案", "产品策略", "业务方案", "路线图"],
|
||||
"avoid_for": ["事故复盘", "安全风险", "学术论文"],
|
||||
"density": "medium",
|
||||
"formality": "medium-high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "graphite_red_risk",
|
||||
"display_name": "Graphite Red Risk",
|
||||
"palette_id": "graphite_red",
|
||||
"typography_id": "system_sans_control",
|
||||
"background_system_id": "risk_panel_grid",
|
||||
"chart_palette_id": "risk_alert_chart",
|
||||
"image_treatment_id": "diagram_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["risk_matrix", "timeline", "control_table", "action_list"],
|
||||
"metadata": {
|
||||
"mood": ["serious", "trustworthy", "controlled"],
|
||||
"tone": ["diagnostic", "analytical", "structured"],
|
||||
"best_for": ["风险诊断", "安全方案", "事故复盘", "合规审计"],
|
||||
"avoid_for": ["品牌发布", "美食介绍", "节庆活动"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "food_culture_warm_editorial",
|
||||
"display_name": "Food Culture Warm Editorial",
|
||||
"palette_id": "warm_coral_jade",
|
||||
"typography_id": "editorial_sans_cjk",
|
||||
"background_system_id": "editorial_photo_panels",
|
||||
"chart_palette_id": "culture_soft_chart",
|
||||
"image_treatment_id": "online_evidence_image",
|
||||
"decoration_policy_id": "source_owned_motif_only",
|
||||
"component_variant_bias": ["image_story", "menu_grid", "place_cards", "timeline"],
|
||||
"metadata": {
|
||||
"mood": ["warm", "editorial", "approachable"],
|
||||
"tone": ["narrative", "cultural", "inviting"],
|
||||
"best_for": ["小吃", "餐饮文化", "城市美食", "旅行介绍"],
|
||||
"avoid_for": ["管理层经营复盘", "安全审计", "技术架构"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "culture_editorial_jade_magenta",
|
||||
"display_name": "Culture Editorial Jade Magenta",
|
||||
"palette_id": "jade_magenta_ink",
|
||||
"typography_id": "editorial_sans_cjk",
|
||||
"background_system_id": "image_caption_grid",
|
||||
"chart_palette_id": "culture_soft_chart",
|
||||
"image_treatment_id": "editorial_photo",
|
||||
"decoration_policy_id": "source_owned_motif_only",
|
||||
"component_variant_bias": ["quote_panel", "timeline", "image_story", "comparison_cards"],
|
||||
"metadata": {
|
||||
"mood": ["cultural", "editorial", "reflective"],
|
||||
"tone": ["narrative", "magazine", "clear"],
|
||||
"best_for": ["地域文化", "语言文化", "城市介绍", "文化科普"],
|
||||
"avoid_for": ["高密度经营看板", "融资 BP", "事故复盘"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "investor_navy_gold",
|
||||
"display_name": "Investor Navy Gold",
|
||||
"palette_id": "navy_gold",
|
||||
"typography_id": "system_sans_executive",
|
||||
"background_system_id": "executive_panels",
|
||||
"chart_palette_id": "investor_chart",
|
||||
"image_treatment_id": "chart_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["market_map", "metric_cards", "traction_chart", "risk_matrix"],
|
||||
"metadata": {
|
||||
"mood": ["confident", "professional", "sharp"],
|
||||
"tone": ["crisp", "analytical", "polished"],
|
||||
"best_for": ["融资 BP", "投资分析", "市场规模", "商业模式"],
|
||||
"avoid_for": ["儿童培训", "文化活动", "小吃介绍"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "education_sky_green",
|
||||
"display_name": "Education Sky Green",
|
||||
"palette_id": "sky_green",
|
||||
"typography_id": "system_sans_learning",
|
||||
"background_system_id": "lesson_step_panels",
|
||||
"chart_palette_id": "learning_chart",
|
||||
"image_treatment_id": "online_evidence_image",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["lesson_steps", "checklist", "exercise_cards", "summary_cards"],
|
||||
"metadata": {
|
||||
"mood": ["approachable", "calm", "practical"],
|
||||
"tone": ["instructional", "clear", "structured"],
|
||||
"best_for": ["培训课件", "课程介绍", "新人 onboarding", "医疗培训"],
|
||||
"avoid_for": ["融资路演", "事故复盘", "潮流发布"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "event_neon_cyan_magenta",
|
||||
"display_name": "Event Neon Cyan Magenta",
|
||||
"palette_id": "neon_cyan_magenta",
|
||||
"typography_id": "display_sans_event",
|
||||
"background_system_id": "media_showcase_grid",
|
||||
"chart_palette_id": "event_signal_chart",
|
||||
"image_treatment_id": "online_event_photo_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["hero_photo", "event_map", "trend_cards", "timeline"],
|
||||
"metadata": {
|
||||
"mood": ["energetic", "confident", "modern"],
|
||||
"tone": ["editorial", "bold", "reporting"],
|
||||
"best_for": ["展会", "ChinaJoy", "活动复盘", "发布会"],
|
||||
"avoid_for": ["董事会经营复盘", "合规审计", "学术政策简报"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "scholarly_vellum_ink",
|
||||
"display_name": "Scholarly Vellum Ink",
|
||||
"palette_id": "vellum_ink_blue",
|
||||
"typography_id": "system_sans_report",
|
||||
"background_system_id": "paper_report_grid",
|
||||
"chart_palette_id": "policy_chart",
|
||||
"image_treatment_id": "chart_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["evidence_table", "method_cards", "findings_grid", "quote_panel"],
|
||||
"metadata": {
|
||||
"mood": ["scholarly", "serious", "trustworthy"],
|
||||
"tone": ["structured", "analytical", "clear"],
|
||||
"best_for": ["研究报告", "政策简报", "学术汇报", "白皮书"],
|
||||
"avoid_for": ["品牌发布", "夜生活活动", "美食菜单"],
|
||||
"density": "high",
|
||||
"formality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "workshop_playful_paper",
|
||||
"display_name": "Workshop Playful Paper",
|
||||
"palette_id": "paper_blue_orange",
|
||||
"typography_id": "system_sans_workshop",
|
||||
"background_system_id": "workshop_board_grid",
|
||||
"chart_palette_id": "workshop_chart",
|
||||
"image_treatment_id": "structured_fallback",
|
||||
"decoration_policy_id": "source_owned_motif_only",
|
||||
"component_variant_bias": ["agenda", "sticky_notes", "exercise_cards", "action_list"],
|
||||
"metadata": {
|
||||
"mood": ["approachable", "warm", "collaborative"],
|
||||
"tone": ["instructional", "facilitative", "clear"],
|
||||
"best_for": ["工作坊", "头脑风暴", "共创", "培训演练"],
|
||||
"avoid_for": ["董事会看板", "安全审计", "融资 BP"],
|
||||
"density": "medium",
|
||||
"formality": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"style_pack_id": "architecture_mono_cyan",
|
||||
"display_name": "Architecture Mono Cyan",
|
||||
"palette_id": "mono_cyan",
|
||||
"typography_id": "system_sans_technical",
|
||||
"background_system_id": "diagram_grid",
|
||||
"chart_palette_id": "technical_chart",
|
||||
"image_treatment_id": "diagram_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
"component_variant_bias": ["architecture_diagram", "dependency_map", "sequence_flow", "risk_matrix"],
|
||||
"metadata": {
|
||||
"mood": ["technical", "focused", "precise"],
|
||||
"tone": ["structured", "analytical", "controlled"],
|
||||
"best_for": ["技术架构", "系统设计", "链路说明", "工程方案"],
|
||||
"avoid_for": ["美食文化", "活动海报", "儿童课程"],
|
||||
"density": "medium-high",
|
||||
"formality": "high"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -93,6 +93,15 @@ ART_DIRECTION_REQUIRED_FIELDS = {
|
||||
"svg_native_moments",
|
||||
}
|
||||
BUSINESS_CLAIM_SOURCE_TYPES = {"prompt_provided", "user_provided", "attachment", "readback", "derived", "assumption", "pending_confirmation"}
|
||||
DESIGN_ASSET_SELECTION_REQUIRED_FIELDS = [
|
||||
"deck_recipe_selection",
|
||||
"template_family_selection",
|
||||
"style_pack_selection",
|
||||
"density_mode_selection",
|
||||
"component_variant_selection",
|
||||
"image_treatment_selection",
|
||||
"style_lock",
|
||||
]
|
||||
|
||||
SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"}
|
||||
RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"}
|
||||
@@ -1968,6 +1977,95 @@ def validate_template_family_policy(plan: dict[str, Any]) -> list[dict[str, Any]
|
||||
return issues
|
||||
|
||||
|
||||
def validate_design_asset_selection_contract(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for field in DESIGN_ASSET_SELECTION_REQUIRED_FIELDS:
|
||||
if not isinstance(plan.get(field), dict):
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
f"plan_missing_{field}",
|
||||
f"SVGlide SVG plans must include deck-level {field}",
|
||||
None,
|
||||
"Run recipe selection before plan/SVG generation and carry the selection metadata into slide_plan.json.",
|
||||
)
|
||||
)
|
||||
if issues:
|
||||
return issues
|
||||
|
||||
recipe = nested_dict(plan.get("deck_recipe_selection"))
|
||||
if recipe.get("match_level") == "L4":
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_recipe_selection_l4",
|
||||
"L4 recipe selection must fail closed before SVG generation",
|
||||
None,
|
||||
"Ask for more content signals or create a reviewed new_recipe_proposal; do not continue with baseline fallback.",
|
||||
)
|
||||
)
|
||||
for field in ["recipe_id", "match_level", "confidence", "signals"]:
|
||||
if recipe.get(field) in (None, "", []):
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
f"plan_recipe_selection_missing_{field}",
|
||||
f"deck_recipe_selection must include {field}",
|
||||
)
|
||||
)
|
||||
|
||||
style_pack = nested_dict(plan.get("style_pack_selection"))
|
||||
image_treatment = nested_dict(plan.get("image_treatment_selection"))
|
||||
style_lock_value = nested_dict(plan.get("style_lock"))
|
||||
if style_lock_value.get("deck_level") is not True:
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_style_lock_not_deck_level",
|
||||
"style_lock must be deck_level=true",
|
||||
None,
|
||||
"Lock style_pack, palette, typography, image treatment, and decoration policy at deck level.",
|
||||
)
|
||||
)
|
||||
if style_lock_value.get("style_pack_id") != style_pack.get("selected_style_pack_id"):
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_style_lock_style_pack_mismatch",
|
||||
"style_lock.style_pack_id must match style_pack_selection.selected_style_pack_id",
|
||||
)
|
||||
)
|
||||
if style_lock_value.get("image_treatment_id") != image_treatment.get("selected_image_treatment_id"):
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_style_lock_image_treatment_mismatch",
|
||||
"style_lock.image_treatment_id must match image_treatment_selection.selected_image_treatment_id",
|
||||
)
|
||||
)
|
||||
if style_lock_value.get("decoration_policy_id") in {"random_decorations", "decorative_noise"}:
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_disallowed_decoration_policy",
|
||||
"random decorative lines or geometric noise are not allowed",
|
||||
None,
|
||||
"Use minimal_grid_only or source-owned motifs with explicit semantic ownership.",
|
||||
)
|
||||
)
|
||||
if normalize_name(textify(plan.get("fallback_policy"))) == "auto":
|
||||
issues.append(
|
||||
plan_issue(
|
||||
"error",
|
||||
"plan_baseline_fallback_policy_auto",
|
||||
"fallback_policy=auto is not allowed for design asset composition plans",
|
||||
None,
|
||||
"Low-confidence routing must fail closed instead of continuing with a baseline theme.",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def slide_visual_plan(slide: dict[str, Any]) -> dict[str, Any]:
|
||||
visual_plan = slide.get("visual_plan")
|
||||
if isinstance(visual_plan, dict):
|
||||
@@ -2678,6 +2776,7 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>") -> dict[str, Any]:
|
||||
)
|
||||
)
|
||||
issues.extend(validate_template_family_policy(plan))
|
||||
issues.extend(validate_design_asset_selection_contract(plan))
|
||||
deck_preset_id = deck_style_preset_id(plan)
|
||||
deck_style_system = style_system(plan)
|
||||
asset_contract_lookup = asset_contracts_by_id(plan)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
305
skills/lark-slides/scripts/svglide_diversity_gate.py
Normal file
305
skills/lark-slides/scripts/svglide_diversity_gate.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PLAN_PATH = Path("02-plan/slide_plan.json")
|
||||
DESIGN_SELECTION_PATH = Path("02-plan/selection-metadata.json")
|
||||
CHECK_DIR = Path("06-check")
|
||||
OUTPUT_PATH = CHECK_DIR / "diversity-gate.json"
|
||||
PASS_ACTION = "continue_pipeline"
|
||||
FAIL_ACTION = "repair_and_rerun"
|
||||
DEFAULT_HISTORY_LIMIT = 8
|
||||
COMBO_REUSE_FAIL = 0.75
|
||||
|
||||
|
||||
class DiversityGateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def read_json_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as error:
|
||||
raise DiversityGateError(f"missing required file: {path}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise DiversityGateError(f"invalid JSON in {path}: {error}") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise DiversityGateError(f"invalid JSON in {path}: expected object")
|
||||
return payload
|
||||
|
||||
|
||||
def read_json_optional(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return read_json_object(path)
|
||||
except (OSError, DiversityGateError):
|
||||
return {}
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def relpath(path: Path, base: Path) -> str:
|
||||
try:
|
||||
return path.resolve().relative_to(base.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def issue(code: str, message: str, *, path: str | None = None, compared_project: str | None = None) -> dict[str, str]:
|
||||
payload = {"code": code, "message": message}
|
||||
if path:
|
||||
payload["path"] = path
|
||||
if compared_project:
|
||||
payload["compared_project"] = compared_project
|
||||
return payload
|
||||
|
||||
|
||||
def list_value(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if str(item).strip()]
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def style_lock_from(plan: dict[str, Any], design_selection: dict[str, Any]) -> dict[str, Any]:
|
||||
lock = plan.get("style_lock")
|
||||
if isinstance(lock, dict):
|
||||
return lock
|
||||
lock = design_selection.get("style_lock")
|
||||
return lock if isinstance(lock, dict) else {}
|
||||
|
||||
|
||||
def template_family_id(plan: dict[str, Any], lock: dict[str, Any]) -> str:
|
||||
raw = lock.get("template_family_id")
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
selection = plan.get("template_family_selection")
|
||||
if isinstance(selection, dict):
|
||||
raw = selection.get("selected_template_id")
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
return "unknown-template-family"
|
||||
|
||||
|
||||
def style_pack_id(lock: dict[str, Any]) -> str:
|
||||
raw = lock.get("style_pack_id")
|
||||
return raw if isinstance(raw, str) and raw else "unknown-style-pack"
|
||||
|
||||
|
||||
def component_variant(slide: dict[str, Any], plan: dict[str, Any]) -> str:
|
||||
raw = slide.get("component_variant") or slide.get("template_variant")
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
selection = slide.get("component_selection")
|
||||
if isinstance(selection, list):
|
||||
ids = [str(item.get("component_id")) for item in selection if isinstance(item, dict) and item.get("component_id")]
|
||||
if ids:
|
||||
return "+".join(ids[:3])
|
||||
deck_selection = plan.get("component_variant_selection")
|
||||
if isinstance(deck_selection, dict):
|
||||
values = list_value(deck_selection.get("selected_component_variants"))
|
||||
if values:
|
||||
return "+".join(values[:3])
|
||||
return "unknown-component"
|
||||
|
||||
|
||||
def layout_variant(slide: dict[str, Any]) -> str:
|
||||
for key in ("layout_variant", "layout_family", "renderer_id", "page_type"):
|
||||
raw = slide.get(key)
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
return "unknown-layout"
|
||||
|
||||
|
||||
def slide_combo(slide: dict[str, Any], plan: dict[str, Any], lock: dict[str, Any]) -> dict[str, str]:
|
||||
spec = slide.get("canvas_spec") if isinstance(slide.get("canvas_spec"), dict) else {}
|
||||
template_id = spec.get("template_id") or template_family_id(plan, lock)
|
||||
return {
|
||||
"page": str(slide.get("page") or ""),
|
||||
"template_id": str(template_id),
|
||||
"template_family_id": template_family_id(plan, lock),
|
||||
"style_pack_id": style_pack_id(lock),
|
||||
"palette_id": str(lock.get("palette_id") or plan.get("project_palette", {}).get("palette_id") or ""),
|
||||
"layout_variant": layout_variant(slide),
|
||||
"component_variant": component_variant(slide, plan),
|
||||
}
|
||||
|
||||
|
||||
def combo_key(combo: dict[str, str]) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
combo.get("template_id", ""),
|
||||
combo.get("style_pack_id", ""),
|
||||
combo.get("layout_variant", ""),
|
||||
combo.get("component_variant", ""),
|
||||
)
|
||||
|
||||
|
||||
def plan_signature(plan: dict[str, Any], design_selection: dict[str, Any]) -> dict[str, Any]:
|
||||
lock = style_lock_from(plan, design_selection)
|
||||
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
|
||||
combos = [slide_combo(slide, plan, lock) for slide in slides if isinstance(slide, dict)]
|
||||
return {
|
||||
"style_lock": lock,
|
||||
"template_family_id": template_family_id(plan, lock),
|
||||
"style_pack_id": style_pack_id(lock),
|
||||
"palette_id": str(lock.get("palette_id") or plan.get("project_palette", {}).get("palette_id") or ""),
|
||||
"image_treatment_id": str(lock.get("image_treatment_id") or ""),
|
||||
"decoration_policy_id": str(lock.get("decoration_policy_id") or ""),
|
||||
"combos": combos,
|
||||
"combo_keys": ["|".join(combo_key(combo)) for combo in combos],
|
||||
}
|
||||
|
||||
|
||||
def validate_current_plan(plan: dict[str, Any], design_selection: dict[str, Any], signature: dict[str, Any]) -> list[dict[str, str]]:
|
||||
issues: list[dict[str, str]] = []
|
||||
lock = signature.get("style_lock") if isinstance(signature.get("style_lock"), dict) else {}
|
||||
if not lock:
|
||||
issues.append(issue("style_lock_missing", "SVGlide selection decks must carry a deck-level style_lock", path="style_lock"))
|
||||
elif lock.get("deck_level") is not True:
|
||||
issues.append(issue("style_lock_not_deck_level", "style_lock.deck_level must be true", path="style_lock.deck_level"))
|
||||
if design_selection and design_selection.get("status") == "failed":
|
||||
issues.append(issue("recipe_selection_failed", "design asset selection failed closed before diversity gate", path=DESIGN_SELECTION_PATH.as_posix()))
|
||||
if not signature.get("combos"):
|
||||
issues.append(issue("diversity_combo_missing", "slides must produce at least one diversity combo", path="slides"))
|
||||
plan_palette = plan.get("project_palette") if isinstance(plan.get("project_palette"), dict) else {}
|
||||
lock_palette = lock.get("palette_id")
|
||||
if isinstance(lock_palette, str) and lock_palette and isinstance(plan_palette.get("style_pack_id"), str):
|
||||
if plan_palette.get("style_pack_id") != lock.get("style_pack_id"):
|
||||
issues.append(issue("style_pack_palette_mismatch", "project_palette.style_pack_id must match style_lock.style_pack_id", path="project_palette.style_pack_id"))
|
||||
slides = plan.get("slides") if isinstance(plan.get("slides"), list) else []
|
||||
for index, slide in enumerate(slides):
|
||||
if not isinstance(slide, dict):
|
||||
continue
|
||||
raw_style = slide.get("style_pack_id")
|
||||
if isinstance(raw_style, str) and raw_style and raw_style != signature.get("style_pack_id"):
|
||||
issues.append(issue("slide_style_pack_drift", "slide-level style_pack_id must not drift from deck style_lock", path=f"slides[{index}].style_pack_id"))
|
||||
spec = slide.get("canvas_spec") if isinstance(slide.get("canvas_spec"), dict) else {}
|
||||
raw_palette = spec.get("palette_id")
|
||||
project_palette_id = plan_palette.get("palette_id")
|
||||
if isinstance(raw_palette, str) and isinstance(project_palette_id, str) and raw_palette and raw_palette != project_palette_id:
|
||||
issues.append(issue("slide_palette_drift", "canvas_spec.palette_id must match project_palette.palette_id", path=f"slides[{index}].canvas_spec.palette_id"))
|
||||
return issues
|
||||
|
||||
|
||||
def recent_project_dirs(project: Path, limit: int) -> list[Path]:
|
||||
root = project.parent
|
||||
if not root.exists():
|
||||
return []
|
||||
candidates = [item for item in root.iterdir() if item.is_dir() and item.resolve() != project.resolve() and (item / PLAN_PATH).exists()]
|
||||
candidates.sort(key=lambda item: (item / PLAN_PATH).stat().st_mtime, reverse=True)
|
||||
return candidates[:limit]
|
||||
|
||||
|
||||
def combo_reuse_ratio(left: list[str], right: list[str]) -> float:
|
||||
if not left or not right:
|
||||
return 0.0
|
||||
left_counts = Counter(left)
|
||||
right_counts = Counter(right)
|
||||
overlap = sum(min(left_counts[key], right_counts[key]) for key in left_counts.keys() & right_counts.keys())
|
||||
return overlap / min(len(left), len(right))
|
||||
|
||||
|
||||
def compare_recent(project: Path, signature: dict[str, Any], *, history_limit: int) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
comparisons: list[dict[str, Any]] = []
|
||||
issues: list[dict[str, str]] = []
|
||||
current_keys = list(signature.get("combo_keys") if isinstance(signature.get("combo_keys"), list) else [])
|
||||
for other_project in recent_project_dirs(project, history_limit):
|
||||
other_plan = read_json_optional(other_project / PLAN_PATH)
|
||||
if not other_plan:
|
||||
continue
|
||||
other_selection = read_json_optional(other_project / DESIGN_SELECTION_PATH)
|
||||
other_signature = plan_signature(other_plan, other_selection)
|
||||
other_keys = list(other_signature.get("combo_keys") if isinstance(other_signature.get("combo_keys"), list) else [])
|
||||
ratio = combo_reuse_ratio([str(item) for item in current_keys], [str(item) for item in other_keys])
|
||||
comparison = {
|
||||
"project": relpath(other_project, project.parent),
|
||||
"title": other_plan.get("title"),
|
||||
"combo_reuse_ratio": round(ratio, 4),
|
||||
"style_pack_id": other_signature.get("style_pack_id"),
|
||||
"template_family_id": other_signature.get("template_family_id"),
|
||||
}
|
||||
comparisons.append(comparison)
|
||||
if ratio >= COMBO_REUSE_FAIL:
|
||||
issues.append(
|
||||
issue(
|
||||
"diversity_combo_reuse_too_high",
|
||||
f"template/style/layout/component combo reuse ratio {ratio:.2f} exceeds {COMBO_REUSE_FAIL:.2f}",
|
||||
compared_project=comparison["project"],
|
||||
)
|
||||
)
|
||||
return comparisons, issues
|
||||
|
||||
|
||||
def run_diversity_gate(project: Path, *, history_limit: int = DEFAULT_HISTORY_LIMIT) -> dict[str, Any]:
|
||||
project = project.resolve()
|
||||
started_at = now_iso()
|
||||
plan = read_json_object(project / PLAN_PATH)
|
||||
design_selection = read_json_optional(project / DESIGN_SELECTION_PATH)
|
||||
signature = plan_signature(plan, design_selection)
|
||||
issues = validate_current_plan(plan, design_selection, signature)
|
||||
comparisons, comparison_issues = compare_recent(project, signature, history_limit=history_limit)
|
||||
issues.extend(comparison_issues)
|
||||
status = "passed" if not issues else "failed"
|
||||
return {
|
||||
"schema_version": "svglide-diversity-gate/v1",
|
||||
"stage": "diversity_gate",
|
||||
"status": status,
|
||||
"action": PASS_ACTION if status == "passed" else FAIL_ACTION,
|
||||
"project": str(project),
|
||||
"started_at": started_at,
|
||||
"ended_at": now_iso(),
|
||||
"inputs": {
|
||||
"slide_plan": PLAN_PATH.as_posix(),
|
||||
"design_selection": DESIGN_SELECTION_PATH.as_posix() if (project / DESIGN_SELECTION_PATH).exists() else None,
|
||||
"history_limit": history_limit,
|
||||
},
|
||||
"signature": signature,
|
||||
"comparisons": comparisons,
|
||||
"summary": {
|
||||
"error_count": len(issues),
|
||||
"comparison_count": len(comparisons),
|
||||
"combo_count": len(signature.get("combos") if isinstance(signature.get("combos"), list) else []),
|
||||
},
|
||||
"issues": issues,
|
||||
"output_path": OUTPUT_PATH.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Review SVGlide design asset composition diversity.")
|
||||
parser.add_argument("project")
|
||||
parser.add_argument("--history-limit", type=int, default=DEFAULT_HISTORY_LIMIT)
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
result = run_diversity_gate(Path(args.project), history_limit=args.history_limit)
|
||||
except DiversityGateError as error:
|
||||
print(f"svglide_diversity_gate: {error}", file=sys.stderr)
|
||||
return 2
|
||||
write_json(Path(args.project) / OUTPUT_PATH, result)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None, sort_keys=True))
|
||||
return 0 if result["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
146
skills/lark-slides/scripts/svglide_diversity_gate_test.py
Normal file
146
skills/lark-slides/scripts/svglide_diversity_gate_test.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import svglide_diversity_gate as gate
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def design_selection(style_pack_id: str = "corporate_blue_data", palette_id: str = "corporate_blue") -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "svglide-design-asset-selection/v1",
|
||||
"status": "passed",
|
||||
"style_pack_selection": {"selected_style_pack_id": style_pack_id, "palette_id": palette_id},
|
||||
"style_lock": {
|
||||
"deck_level": True,
|
||||
"template_family_id": "blue-professional",
|
||||
"style_pack_id": style_pack_id,
|
||||
"palette_id": palette_id,
|
||||
"image_treatment_id": "chart_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def plan_payload(style_pack_id: str = "corporate_blue_data", palette_id: str = "style_pack.corporate_blue_data") -> dict[str, object]:
|
||||
return {
|
||||
"title": "内部业务复盘",
|
||||
"template_family_selection": {
|
||||
"enabled": True,
|
||||
"source": "beautiful-html-template-families",
|
||||
"selected_template_id": "blue-professional",
|
||||
"candidate_template_ids": ["blue-professional"],
|
||||
"selection_reason": "test",
|
||||
},
|
||||
"style_lock": {
|
||||
"deck_level": True,
|
||||
"template_family_id": "blue-professional",
|
||||
"style_pack_id": style_pack_id,
|
||||
"palette_id": "corporate_blue",
|
||||
"image_treatment_id": "chart_first",
|
||||
"decoration_policy_id": "minimal_grid_only",
|
||||
},
|
||||
"project_palette": {
|
||||
"palette_id": palette_id,
|
||||
"source": "style_pack_registry",
|
||||
"confidence": "high",
|
||||
"style_pack_id": style_pack_id,
|
||||
"colors": {"background": "#F8FAFF", "text": "#111827", "primary": "#1E3A8A", "accent": "#2563EB"},
|
||||
},
|
||||
"component_variant_selection": {"selected_component_variants": ["metric_cards", "comparison_matrix"]},
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"layout_family": "cover",
|
||||
"template_variant": "cover",
|
||||
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
|
||||
"component_selection": [{"component_id": "title_block"}],
|
||||
},
|
||||
{
|
||||
"page": 2,
|
||||
"layout_family": "dashboard",
|
||||
"template_variant": "kpi_strip",
|
||||
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
|
||||
"component_selection": [{"component_id": "metric_cards"}],
|
||||
},
|
||||
{
|
||||
"page": 3,
|
||||
"layout_family": "closing",
|
||||
"template_variant": "closing",
|
||||
"canvas_spec": {"template_id": "executive-dashboard", "palette_id": palette_id},
|
||||
"component_selection": [{"component_id": "action_list"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_project(project: Path, plan: dict[str, object] | None = None, selection: dict[str, object] | None = None) -> None:
|
||||
write_json(project / "02-plan/slide_plan.json", plan or plan_payload())
|
||||
write_json(project / "02-plan/selection-metadata.json", selection or design_selection())
|
||||
|
||||
|
||||
class SVGlideDiversityGateTest(unittest.TestCase):
|
||||
def test_passes_with_deck_level_style_lock_and_varied_layouts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir) / "current"
|
||||
write_project(project)
|
||||
|
||||
result = gate.run_diversity_gate(project)
|
||||
|
||||
self.assertEqual(result["status"], "passed", result["issues"])
|
||||
self.assertEqual(result["summary"]["combo_count"], 3)
|
||||
|
||||
def test_fails_missing_style_lock(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir) / "current"
|
||||
plan = plan_payload()
|
||||
plan.pop("style_lock")
|
||||
write_json(project / "02-plan/slide_plan.json", plan)
|
||||
|
||||
result = gate.run_diversity_gate(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("style_lock_missing", {item["code"] for item in result["issues"]})
|
||||
|
||||
def test_fails_slide_palette_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project = Path(tmpdir) / "current"
|
||||
plan = plan_payload()
|
||||
slides = plan["slides"] # type: ignore[index]
|
||||
slides[1]["canvas_spec"]["palette_id"] = "style_pack.graphite_red_risk" # type: ignore[index]
|
||||
write_project(project, plan=plan)
|
||||
|
||||
result = gate.run_diversity_gate(project)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("slide_palette_drift", {item["code"] for item in result["issues"]})
|
||||
|
||||
def test_fails_recent_combo_reuse_too_high(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
previous = root / "previous"
|
||||
current = root / "current"
|
||||
write_project(previous)
|
||||
write_project(current)
|
||||
|
||||
result = gate.run_diversity_gate(current)
|
||||
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("diversity_combo_reuse_too_high", {item["code"] for item in result["issues"]})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"))
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
361
skills/lark-slides/scripts/svglide_recipe_selector.py
Normal file
361
skills/lark-slides/scripts/svglide_recipe_selector.py
Normal file
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import svglide_semantic_asset_matcher
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
LARK_SLIDES_DIR = SCRIPT_DIR.parent
|
||||
REFERENCES_DIR = LARK_SLIDES_DIR / "references"
|
||||
RECIPE_REGISTRY_PATH = REFERENCES_DIR / "svglide-deck-recipe-registry.json"
|
||||
STYLE_PACK_REGISTRY_PATH = REFERENCES_DIR / "svglide-style-pack-registry.json"
|
||||
|
||||
MATCH_LEVEL_THRESHOLDS = {
|
||||
"L1": 32,
|
||||
"L2": 16,
|
||||
"L3": 8,
|
||||
}
|
||||
|
||||
REQUIRED_RECIPE_METADATA_FIELDS = {"mood", "tone", "best_for", "avoid_for", "density", "formality"}
|
||||
NOISE_ONLY_RE = re.compile(r"随便|高级感|好看|酷炫|没有主题|灵感合集|whatever|make it nice", re.IGNORECASE)
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"expected JSON object: {path}")
|
||||
return payload
|
||||
|
||||
|
||||
def load_recipe_registry(path: Path = RECIPE_REGISTRY_PATH) -> list[dict[str, Any]]:
|
||||
payload = load_json(path)
|
||||
recipes = payload.get("recipes")
|
||||
if not isinstance(recipes, list):
|
||||
raise ValueError(f"{path} must contain recipes[]")
|
||||
return [item for item in recipes if isinstance(item, dict)]
|
||||
|
||||
|
||||
def load_style_pack_registry(path: Path = STYLE_PACK_REGISTRY_PATH) -> list[dict[str, Any]]:
|
||||
payload = load_json(path)
|
||||
packs = payload.get("style_packs")
|
||||
if not isinstance(packs, list):
|
||||
raise ValueError(f"{path} must contain style_packs[]")
|
||||
return [item for item in packs if isinstance(item, dict)]
|
||||
|
||||
|
||||
def normalize_text(value: Any) -> str:
|
||||
return str(value or "").strip().lower().replace("_", " ").replace("-", " ")
|
||||
|
||||
|
||||
def text_values(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [item for child in value for item in text_values(child)]
|
||||
if isinstance(value, dict):
|
||||
return [item for child in value.values() for item in text_values(child)]
|
||||
text = normalize_text(value)
|
||||
return [text] if text else []
|
||||
|
||||
|
||||
def prompt_contains(prompt_norm: str, signal: str) -> bool:
|
||||
signal_norm = normalize_text(signal)
|
||||
if not signal_norm:
|
||||
return False
|
||||
if re.fullmatch(r"[a-z0-9 ]+", signal_norm):
|
||||
return bool(re.search(rf"(?<![a-z0-9]){re.escape(signal_norm)}(?![a-z0-9])", prompt_norm))
|
||||
return signal_norm in prompt_norm
|
||||
|
||||
|
||||
def matching_values(prompt_norm: str, values: Any) -> list[str]:
|
||||
matched: list[str] = []
|
||||
for value in text_values(values):
|
||||
if prompt_contains(prompt_norm, value):
|
||||
matched.append(value)
|
||||
return sorted(set(matched))
|
||||
|
||||
|
||||
def registry_text(recipe: dict[str, Any]) -> list[str]:
|
||||
fields = [
|
||||
"keywords",
|
||||
"intent_tags",
|
||||
"content_shape",
|
||||
"audience",
|
||||
"component_slots",
|
||||
"image_treatment_candidates",
|
||||
]
|
||||
values: list[str] = []
|
||||
for field in fields:
|
||||
values.extend(text_values(recipe.get(field)))
|
||||
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
|
||||
values.extend(text_values(metadata.get("best_for")))
|
||||
return values
|
||||
|
||||
|
||||
def prompt_specific_enough(prompt: str) -> bool:
|
||||
prompt_norm = normalize_text(prompt)
|
||||
if len(prompt_norm) < 6:
|
||||
return False
|
||||
if NOISE_ONLY_RE.search(prompt_norm) and not svglide_semantic_asset_matcher.infer_brief_signals(prompt):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def score_recipe(prompt: str, recipe: dict[str, Any]) -> dict[str, Any]:
|
||||
prompt_norm = normalize_text(prompt)
|
||||
score = 0
|
||||
signals: dict[str, list[str]] = {}
|
||||
|
||||
keyword_matches = matching_values(prompt_norm, recipe.get("keywords"))
|
||||
score += len(keyword_matches) * 8
|
||||
if keyword_matches:
|
||||
signals["keywords"] = keyword_matches
|
||||
|
||||
for field, weight in [
|
||||
("intent_tags", 5),
|
||||
("content_shape", 5),
|
||||
("audience", 4),
|
||||
("component_slots", 3),
|
||||
("image_treatment_candidates", 3),
|
||||
]:
|
||||
matches = matching_values(prompt_norm, recipe.get(field))
|
||||
if matches:
|
||||
score += len(matches) * weight
|
||||
signals[field] = matches
|
||||
|
||||
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
|
||||
for field, weight in [("best_for", 5), ("mood", 2), ("tone", 2)]:
|
||||
matches = matching_values(prompt_norm, metadata.get(field))
|
||||
if matches:
|
||||
score += len(matches) * weight
|
||||
signals[f"metadata.{field}"] = matches
|
||||
|
||||
inferred = svglide_semantic_asset_matcher.infer_brief_signals(prompt)
|
||||
inferred_values = [value for value in text_values(inferred) if value]
|
||||
registry_values = set(registry_text(recipe))
|
||||
inferred_matches = sorted({value for value in inferred_values if value in registry_values})
|
||||
if inferred_matches:
|
||||
score += len(inferred_matches) * 3
|
||||
signals["semantic_asset_matcher"] = inferred_matches
|
||||
|
||||
avoid_matches = matching_values(prompt_norm, recipe.get("avoid_when"))
|
||||
if avoid_matches:
|
||||
score -= len(avoid_matches) * 12
|
||||
signals["avoid_when"] = avoid_matches
|
||||
|
||||
return {
|
||||
"recipe_id": recipe.get("recipe_id"),
|
||||
"score": max(score, 0),
|
||||
"signals": signals,
|
||||
}
|
||||
|
||||
|
||||
def match_level(score: int) -> str:
|
||||
if score >= MATCH_LEVEL_THRESHOLDS["L1"]:
|
||||
return "L1"
|
||||
if score >= MATCH_LEVEL_THRESHOLDS["L2"]:
|
||||
return "L2"
|
||||
if score >= MATCH_LEVEL_THRESHOLDS["L3"]:
|
||||
return "L3"
|
||||
return "L4"
|
||||
|
||||
|
||||
def confidence_for(score: int, level: str) -> float:
|
||||
if level == "L4":
|
||||
return 0.0
|
||||
if level == "L3":
|
||||
return round(min(0.49, 0.32 + score / 80), 2)
|
||||
if level == "L2":
|
||||
return round(min(0.74, 0.5 + score / 100), 2)
|
||||
return round(min(0.95, 0.72 + score / 180), 2)
|
||||
|
||||
|
||||
def missing_signals_for(prompt: str, scored: list[dict[str, Any]]) -> list[str]:
|
||||
prompt_norm = normalize_text(prompt)
|
||||
missing = ["intent", "content_shape", "audience"]
|
||||
if not prompt_norm:
|
||||
missing.append("prompt")
|
||||
if scored and scored[0]["score"] > 0:
|
||||
missing.append("sufficient_recipe_confidence")
|
||||
if NOISE_ONLY_RE.search(prompt_norm):
|
||||
missing.append("specific_subject")
|
||||
return sorted(set(missing))
|
||||
|
||||
|
||||
def style_pack_score(prompt: str, recipe: dict[str, Any], pack: dict[str, Any]) -> int:
|
||||
prompt_norm = normalize_text(prompt)
|
||||
score = 0
|
||||
metadata = pack.get("metadata") if isinstance(pack.get("metadata"), dict) else {}
|
||||
for field, weight in [("best_for", 7), ("mood", 3), ("tone", 3)]:
|
||||
score += len(matching_values(prompt_norm, metadata.get(field))) * weight
|
||||
if pack.get("style_pack_id") in recipe.get("style_pack_candidates", []):
|
||||
score += 20
|
||||
if pack.get("image_treatment_id") in recipe.get("image_treatment_candidates", []):
|
||||
score += 4
|
||||
avoid = matching_values(prompt_norm, metadata.get("avoid_for"))
|
||||
score -= len(avoid) * 10
|
||||
return score
|
||||
|
||||
|
||||
def select_style_pack(prompt: str, recipe: dict[str, Any], packs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
candidate_ids = set(recipe.get("style_pack_candidates", []))
|
||||
candidates = [pack for pack in packs if pack.get("style_pack_id") in candidate_ids]
|
||||
if not candidates:
|
||||
candidates = packs
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda pack: (-style_pack_score(prompt, recipe, pack), str(pack.get("style_pack_id"))),
|
||||
)
|
||||
return ranked[0]
|
||||
|
||||
|
||||
def build_failed_selection(prompt: str, scored: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
best = scored[0] if scored else {"recipe_id": None, "score": 0, "signals": {}}
|
||||
return {
|
||||
"schema_version": "svglide-design-asset-selection/v1",
|
||||
"status": "failed",
|
||||
"action": "fail_closed",
|
||||
"prompt": prompt,
|
||||
"deck_recipe_selection": {
|
||||
"recipe_id": best.get("recipe_id"),
|
||||
"match_level": "L4",
|
||||
"confidence": 0.0,
|
||||
"signals": best.get("signals") or {},
|
||||
"missing_signals": missing_signals_for(prompt, scored),
|
||||
"candidate_recipe_ids": [str(item["recipe_id"]) for item in scored[:5] if item.get("recipe_id")],
|
||||
"selection_reason": "No recipe reached the minimum confidence required to generate.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_success_selection(
|
||||
prompt: str,
|
||||
recipe: dict[str, Any],
|
||||
recipe_score: dict[str, Any],
|
||||
style_pack: dict[str, Any],
|
||||
scored: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
level = match_level(int(recipe_score["score"]))
|
||||
template_candidates = [str(item) for item in recipe.get("template_family_candidates", [])]
|
||||
style_pack_id = str(style_pack["style_pack_id"])
|
||||
image_treatment_id = str(style_pack["image_treatment_id"])
|
||||
density_modes = [str(item) for item in recipe.get("density_modes", [])]
|
||||
component_slots = [str(item) for item in recipe.get("component_slots", [])]
|
||||
decoration_policy_id = str(style_pack["decoration_policy_id"])
|
||||
return {
|
||||
"schema_version": "svglide-design-asset-selection/v1",
|
||||
"status": "passed",
|
||||
"action": "generate",
|
||||
"prompt": prompt,
|
||||
"deck_recipe_selection": {
|
||||
"recipe_id": recipe["recipe_id"],
|
||||
"display_name": recipe.get("display_name"),
|
||||
"match_level": level,
|
||||
"confidence": confidence_for(int(recipe_score["score"]), level),
|
||||
"signals": recipe_score["signals"],
|
||||
"missing_signals": [],
|
||||
"candidate_recipe_ids": [str(item["recipe_id"]) for item in scored[:5] if item.get("recipe_id")],
|
||||
"selection_reason": "Matched recipe metadata from prompt intent, content shape, audience, and asset signals.",
|
||||
},
|
||||
"template_family_selection": {
|
||||
"enabled": True,
|
||||
"source": "beautiful-html-template-families",
|
||||
"selected_template_id": template_candidates[0],
|
||||
"candidate_template_ids": template_candidates,
|
||||
"selection_reason": f"Recipe {recipe['recipe_id']} prefers this template family for structure; final visual skin comes from style_pack.",
|
||||
},
|
||||
"style_pack_selection": {
|
||||
"selected_style_pack_id": style_pack_id,
|
||||
"candidate_style_pack_ids": [str(item) for item in recipe.get("style_pack_candidates", [])],
|
||||
"selection_reason": f"Selected {style_pack_id} because it matches the recipe tone and image treatment requirements.",
|
||||
"palette_id": style_pack["palette_id"],
|
||||
"typography_id": style_pack["typography_id"],
|
||||
"background_system_id": style_pack["background_system_id"],
|
||||
"chart_palette_id": style_pack["chart_palette_id"],
|
||||
"image_treatment_id": image_treatment_id,
|
||||
"decoration_policy_id": decoration_policy_id,
|
||||
"component_variant_bias": style_pack["component_variant_bias"],
|
||||
},
|
||||
"density_mode_selection": {
|
||||
"selected_density_mode": density_modes[0],
|
||||
"candidate_density_modes": density_modes,
|
||||
"selection_reason": "Density mode follows the matched deck recipe rather than per-page reinvention.",
|
||||
},
|
||||
"component_variant_selection": {
|
||||
"selected_component_variants": component_slots[:6],
|
||||
"candidate_component_variants": component_slots,
|
||||
"selection_reason": "Component variants are constrained by recipe slots and style_pack bias.",
|
||||
},
|
||||
"image_treatment_selection": {
|
||||
"selected_image_treatment_id": image_treatment_id,
|
||||
"candidate_image_treatment_ids": [str(item) for item in recipe.get("image_treatment_candidates", [])],
|
||||
"selection_reason": "Image treatment is locked at deck level; local preview and live create must consume the same metadata.",
|
||||
},
|
||||
"style_lock": {
|
||||
"template_family_id": template_candidates[0],
|
||||
"style_pack_id": style_pack_id,
|
||||
"palette_id": style_pack["palette_id"],
|
||||
"typography_id": style_pack["typography_id"],
|
||||
"background_system_id": style_pack["background_system_id"],
|
||||
"chart_palette_id": style_pack["chart_palette_id"],
|
||||
"image_treatment_id": image_treatment_id,
|
||||
"decoration_policy_id": decoration_policy_id,
|
||||
"component_variant_bias": style_pack["component_variant_bias"],
|
||||
"deck_level": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_registry_contracts(recipes: list[dict[str, Any]], packs: list[dict[str, Any]]) -> None:
|
||||
style_pack_ids = {pack.get("style_pack_id") for pack in packs}
|
||||
for recipe in recipes:
|
||||
metadata = recipe.get("metadata") if isinstance(recipe.get("metadata"), dict) else {}
|
||||
missing = REQUIRED_RECIPE_METADATA_FIELDS - set(metadata)
|
||||
if missing:
|
||||
raise ValueError(f"recipe {recipe.get('recipe_id')} missing metadata fields: {sorted(missing)}")
|
||||
if not set(recipe.get("style_pack_candidates", [])) & style_pack_ids:
|
||||
raise ValueError(f"recipe {recipe.get('recipe_id')} has no valid style_pack_candidates")
|
||||
|
||||
|
||||
def select_design_assets(prompt: str) -> dict[str, Any]:
|
||||
recipes = load_recipe_registry()
|
||||
packs = load_style_pack_registry()
|
||||
validate_registry_contracts(recipes, packs)
|
||||
scored = sorted(
|
||||
[score_recipe(prompt, recipe) for recipe in recipes],
|
||||
key=lambda item: (-int(item["score"]), str(item.get("recipe_id"))),
|
||||
)
|
||||
if not prompt_specific_enough(prompt) or not scored:
|
||||
return build_failed_selection(prompt, scored)
|
||||
best = scored[0]
|
||||
level = match_level(int(best["score"]))
|
||||
if level == "L4":
|
||||
return build_failed_selection(prompt, scored)
|
||||
recipes_by_id = {recipe["recipe_id"]: recipe for recipe in recipes}
|
||||
recipe = recipes_by_id[str(best["recipe_id"])]
|
||||
style_pack = select_style_pack(prompt, recipe, packs)
|
||||
return build_success_selection(prompt, recipe, best, style_pack, scored)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Select SVGlide deck recipe and composable design assets.")
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--out", default=None)
|
||||
args = parser.parse_args()
|
||||
result = select_design_assets(args.prompt)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
if args.out:
|
||||
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(text)
|
||||
return 0 if result.get("status") == "passed" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
151
skills/lark-slides/scripts/svglide_recipe_selector_test.py
Normal file
151
skills/lark-slides/scripts/svglide_recipe_selector_test.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import svglide_recipe_selector as selector
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REFERENCES_DIR = SCRIPT_DIR.parent / "references"
|
||||
FIXTURE_DIR = SCRIPT_DIR / "fixtures" / "svglide_recipe_matching"
|
||||
LEVEL_RANK = {"L1": 1, "L2": 2, "L3": 3, "L4": 4}
|
||||
REQUIRED_METADATA_FIELDS = {"mood", "tone", "best_for", "avoid_for", "density", "formality"}
|
||||
FORBIDDEN_BASELINES = {"svglide-baseline", "svglide baseline theme", "baseline"}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise AssertionError(f"{path} must contain a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
class SVGlideRecipeSelectorTest(unittest.TestCase):
|
||||
def test_registry_contract_files_exist(self) -> None:
|
||||
for name in [
|
||||
"svglide-deck-recipe-registry.schema.json",
|
||||
"svglide-deck-recipe-registry.json",
|
||||
"svglide-style-pack-registry.schema.json",
|
||||
"svglide-style-pack-registry.json",
|
||||
"svglide-semantic-route-cases.schema.json",
|
||||
"svglide-semantic-route-cases.json",
|
||||
]:
|
||||
self.assertTrue((REFERENCES_DIR / name).exists(), name)
|
||||
|
||||
def test_registries_have_routeable_metadata(self) -> None:
|
||||
recipes = selector.load_recipe_registry()
|
||||
style_packs = selector.load_style_pack_registry()
|
||||
self.assertGreaterEqual(len(recipes), 10)
|
||||
self.assertGreaterEqual(len(style_packs), 8)
|
||||
style_pack_ids = {item["style_pack_id"] for item in style_packs}
|
||||
for recipe in recipes:
|
||||
with self.subTest(recipe=recipe.get("recipe_id")):
|
||||
self.assertTrue(recipe.get("recipe_id"))
|
||||
self.assertTrue(REQUIRED_METADATA_FIELDS.issubset(recipe.get("metadata", {})))
|
||||
self.assertTrue(recipe.get("template_family_candidates"))
|
||||
self.assertTrue(recipe.get("style_pack_candidates"))
|
||||
self.assertTrue(recipe.get("component_slots"))
|
||||
self.assertTrue(recipe.get("image_treatment_candidates"))
|
||||
self.assertTrue(set(recipe["style_pack_candidates"]) & style_pack_ids)
|
||||
for pack in style_packs:
|
||||
with self.subTest(style_pack=pack.get("style_pack_id")):
|
||||
self.assertTrue(pack.get("style_pack_id"))
|
||||
self.assertTrue(REQUIRED_METADATA_FIELDS.issubset(pack.get("metadata", {})))
|
||||
for field in [
|
||||
"palette_id",
|
||||
"typography_id",
|
||||
"background_system_id",
|
||||
"chart_palette_id",
|
||||
"image_treatment_id",
|
||||
"decoration_policy_id",
|
||||
"component_variant_bias",
|
||||
]:
|
||||
self.assertIn(field, pack)
|
||||
|
||||
def test_42_golden_cases_are_all_l1_or_l2(self) -> None:
|
||||
payload = load_json(FIXTURE_DIR / "cases_42.json")
|
||||
cases = payload["cases"]
|
||||
self.assertEqual(len(cases), 42)
|
||||
results = []
|
||||
for case in cases:
|
||||
with self.subTest(case_id=case["case_id"]):
|
||||
result = selector.select_design_assets(case["prompt"])
|
||||
recipe = result["deck_recipe_selection"]
|
||||
self.assertIn(recipe["recipe_id"], case["expected_recipe_ids"])
|
||||
self.assertLessEqual(LEVEL_RANK[recipe["match_level"]], LEVEL_RANK[case["min_match_level"]])
|
||||
self.assertIn(recipe["match_level"], {"L1", "L2"})
|
||||
self.assertGreaterEqual(recipe["confidence"], 0.5)
|
||||
self.assert_complete_selection(result)
|
||||
results.append(recipe["match_level"])
|
||||
self.assertEqual(len([level for level in results if level in {"L1", "L2"}]), 42)
|
||||
|
||||
def test_out_of_sample_routes_or_fails_closed_with_explanation(self) -> None:
|
||||
payload = load_json(FIXTURE_DIR / "out_of_sample.json")
|
||||
cases = payload["cases"]
|
||||
self.assertGreaterEqual(len(cases), 9)
|
||||
observed_non_l1 = False
|
||||
for case in cases:
|
||||
with self.subTest(case_id=case["case_id"]):
|
||||
result = selector.select_design_assets(case["prompt"])
|
||||
recipe = result["deck_recipe_selection"]
|
||||
self.assertLessEqual(LEVEL_RANK[recipe["match_level"]], LEVEL_RANK[case["max_match_level"]])
|
||||
if recipe["match_level"] != "L1":
|
||||
observed_non_l1 = True
|
||||
if recipe["match_level"] == "L4":
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["action"], "fail_closed")
|
||||
self.assertTrue(recipe["missing_signals"])
|
||||
else:
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertTrue(recipe["signals"])
|
||||
self.assert_complete_selection(result)
|
||||
self.assertTrue(observed_non_l1)
|
||||
|
||||
def test_selection_never_uses_baseline_as_successful_fallback(self) -> None:
|
||||
for prompt in ["随便做一个好看的 PPT", "完全没有主题,只要高级感", ""]:
|
||||
with self.subTest(prompt=prompt):
|
||||
result = selector.select_design_assets(prompt)
|
||||
blob = json.dumps(result, ensure_ascii=False).lower()
|
||||
self.assertFalse(any(item in blob for item in FORBIDDEN_BASELINES))
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["action"], "fail_closed")
|
||||
|
||||
def test_style_lock_is_deck_level_and_auditable(self) -> None:
|
||||
result = selector.select_design_assets("豆包 App 竞品分析,关注产品能力、用户场景和真实产品截图")
|
||||
self.assertEqual(result["status"], "passed")
|
||||
style_lock = result["style_lock"]
|
||||
self.assertEqual(style_lock["style_pack_id"], result["style_pack_selection"]["selected_style_pack_id"])
|
||||
self.assertEqual(style_lock["image_treatment_id"], result["image_treatment_selection"]["selected_image_treatment_id"])
|
||||
self.assertNotEqual(style_lock["decoration_policy_id"], "random_decorations")
|
||||
self.assertIn("selection_reason", result["style_pack_selection"])
|
||||
self.assertIn("selection_reason", result["image_treatment_selection"])
|
||||
|
||||
def assert_complete_selection(self, result: dict) -> None:
|
||||
for key in [
|
||||
"deck_recipe_selection",
|
||||
"template_family_selection",
|
||||
"style_pack_selection",
|
||||
"density_mode_selection",
|
||||
"component_variant_selection",
|
||||
"image_treatment_selection",
|
||||
"style_lock",
|
||||
]:
|
||||
self.assertIn(key, result)
|
||||
self.assertEqual(result["template_family_selection"]["enabled"], True)
|
||||
self.assertTrue(result["template_family_selection"]["selected_template_id"])
|
||||
self.assertTrue(result["style_pack_selection"]["selected_style_pack_id"])
|
||||
self.assertTrue(result["density_mode_selection"]["selected_density_mode"])
|
||||
self.assertTrue(result["component_variant_selection"]["selected_component_variants"])
|
||||
self.assertTrue(result["image_treatment_selection"]["selected_image_treatment_id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user