diff --git a/skills/lark-slides/references/lark-slides-create-svg.md b/skills/lark-slides/references/lark-slides-create-svg.md index 6c5126076..0a3561713 100644 --- a/skills/lark-slides/references/lark-slides-create-svg.md +++ b/skills/lark-slides/references/lark-slides-create-svg.md @@ -162,7 +162,7 @@ seed skeleton 不是灵感参考,而是初始版式骨架。生成器必须保 SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan//slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。 -当需要把生成、prepare、preview、preflight、preview_lint、quality_gate、dry-run、PPE proof、live create 和 readback 串成可恢复流水线时,项目级 `project_manifest.json`、`state.json`、`prepared/` 和 `receipts/` 规则见 [svglide-project-pipeline.md](svglide-project-pipeline.md)。该文档只管本地执行状态,不替代 `slide_plan.json`、SVG 协议或 route-private 规则。 +当需要把 source、strategy、generate、prepare、preview、preflight、preview_lint、chart_verify、quality_gate、dry-run、PPE proof、live create 和 readback 串成可恢复流水线时,项目级 `project_manifest.json`、`state.json`、`prepared/` 和 `receipts/` 规则见 [svglide-project-pipeline.md](svglide-project-pipeline.md)。该文档只管本地执行状态,不替代 `slide_plan.json`、SVG 协议或 route-private 规则。 在通用 plan 字段基础上,SVG deck 还应补充这些 SVG 专属字段: @@ -255,6 +255,8 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, `primitives`、`renderer_id` 或 component id。`quality_gate` 会核对这些证据; 只在 plan 里声明但 SVG/component report 没有落地时不得进入 dry-run/live。 +Renderer 的可执行覆盖面由 [svglide-renderer-registry.json](svglide-renderer-registry.json) 声明。只有 `status=active` 且能映射到现有 `svg-seeds.json` 与 `svg-recipes.json` 的 renderer 才能被自动选择;candidate renderer 只能用于实验或手工样张。 + 模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes,不要照搬模板 XML,也不要读取完整模板 XML。 SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实: diff --git a/skills/lark-slides/references/routes/create-svg/route.manifest.json b/skills/lark-slides/references/routes/create-svg/route.manifest.json index 1bdd2f377..8e9d696b2 100644 --- a/skills/lark-slides/references/routes/create-svg/route.manifest.json +++ b/skills/lark-slides/references/routes/create-svg/route.manifest.json @@ -13,7 +13,9 @@ "references/svglide-project-pipeline.md", "references/asset-planning.md", "references/svg-visual-recipes.md", - "references/svg-aesthetic-review.md" + "references/svg-aesthetic-review.md", + "references/svglide-absorption-matrix.md", + "references/svglide-renderer-registry.json" ], "private_policy": { "private_recipe_manifest": "references/routes/create-svg/private-recipes.manifest.json", diff --git a/skills/lark-slides/references/svglide-absorption-matrix.md b/skills/lark-slides/references/svglide-absorption-matrix.md new file mode 100644 index 000000000..f91d6520f --- /dev/null +++ b/skills/lark-slides/references/svglide-absorption-matrix.md @@ -0,0 +1,28 @@ +# SVGlide Reference Absorption Matrix + +This matrix tracks which high-quality presentation-generation capabilities have been absorbed into the SVGlide SVG route. It is intentionally written in SVGlide terms: the CLI must run independently and must not depend on an external reference project at runtime. + +| Capability | Status | CLI Landing Area | Acceptance Evidence | +|---|---|---|---| +| Project state machine | absorbed | `svglide_project_runner.py` | stages emit receipts and `receipts/timings.json` | +| Source pack | absorbed | `source/source_pack.json`, `slide_plan.source_pack` | generation receipt includes `source_pack_digest` and status | +| Design spec lock | planned | `source/design_spec.json`, `slide_plan.strategy_locks` | strategy receipt lists mode, visual style, style preset, chart policy | +| Renderer registry | planned | `svglide-renderer-registry.json` | active renderers validate against seed and recipe catalogs | +| Layout contracts | absorbed | `svg-seeds.json` | active slides carry `seed_id`, `layout_boxes`, budgets, safe zone | +| Visual recipes | absorbed | `svg-recipes.json` | active slides carry `visual_recipe` and required primitives | +| Style system | absorbed | `style-presets.json` | active plan carries `style_preset` and `style_system` | +| Design pattern selection | absorbed | `design_pattern_selection`, `receipts/design-pattern-usage.json` | selected assets are proven by component report geometry | +| Renderer assetization | planned | `svglide_gen_runtime.py`, renderer registry | each active renderer has page kind, seed, recipe, and runtime family | +| Chart geometry verification | planned | `chart_verify` runner stage | chart pages emit `receipts/chart-verify.json` before quality gate | +| Preview lint | absorbed | `svg_preview_lint.py` | `preview_lint` receipt includes score, issues, and validation profile | +| Quality gate | absorbed | `quality_gate` runner stage | gate aggregates preflight, preview lint, components, design usage | +| Timing receipt | absorbed | `receipts/timings.json` | every runner stage records elapsed time and over-budget status | +| Golden smoke suite | absorbed | `svglide_golden_suite.py` | built-in cases cover AI capital, Aksu oasis, runtime smoke | +| Editable PPTX export | not_applicable | outside SVGlide SVG route | SVG route publishes through `slides +create-svg` | +| PowerPoint animation/audio/video | not_applicable | outside SVGlide SVG route | not required for Lark Slides SVG create flow | + +## Rules + +- Runtime assets must use SVGlide-native names. +- External examples can inform contracts, but raw files are not copied into the CLI runtime path. +- A capability is not `absorbed` unless it has a receipt, validator, test, or golden case that proves it is exercised. diff --git a/skills/lark-slides/references/svglide-project-pipeline.md b/skills/lark-slides/references/svglide-project-pipeline.md index 538f375f5..a98897ec1 100644 --- a/skills/lark-slides/references/svglide-project-pipeline.md +++ b/skills/lark-slides/references/svglide-project-pipeline.md @@ -28,7 +28,7 @@ It does not own: ## Stage Order ```text -generate -> prepare -> preview -> preflight -> preview_lint -> quality_gate -> dry_run -> ppe_proof -> live_create -> readback +source -> strategy -> generate -> prepare -> preview -> preflight -> preview_lint -> chart_verify -> quality_gate -> dry_run -> ppe_proof -> live_create -> readback ``` `render_contact_sheet` is an optional artifact stage after readback/raster @@ -48,6 +48,7 @@ receipts. It is not part of the default `--until dry_run` authoring path. inputs.json evidence.json source_pack.json + design_spec.json slide_plan.json assets/ asset_manifest.json @@ -58,20 +59,27 @@ receipts. It is not part of the default `--until dry_run` authoring path. preview/ preview.html logs/ + source.log + strategy.log generate.log prepare.log preview.log preflight.log preview-lint.log + chart-verify.log dry-run.log live-create.log readback.log receipts/ timings.json env.json + source.json + strategy.json prepare.json preflight.json preview-lint.json + chart-verify.json + quality-gate.json dry-run.json live-create.json readback.json @@ -137,7 +145,9 @@ Pure topic input must first become structured source state before page SVG is rendered. Store the original brief in `source/brief.md`; store source status, evidence ids, numeric-claim policy, and missing-source notes in `source/source_pack.json` or top-level `slide_plan.source_pack`. Research notes -or citation indexes belong in `source/evidence.json` when they exist. +or citation indexes belong in `source/evidence.json` when they exist. The +runner-owned `source` stage writes these files before generation when the +project starts from a prompt or manifest brief. `slide_plan.json` must keep strategy decisions in the existing plan surface: @@ -150,14 +160,21 @@ or citation indexes belong in `source/evidence.json` when they exist. - page-level `source_refs`, `asset_selection_reason`, `rejected_asset_alternatives`, `chart_decision`, and `chart_verification`. +The runner-owned `strategy` stage writes `source/design_spec.json` and refreshes +`slide_plan.json` with the current strategy locks, style system, renderer +selection, source pack reference, and design spec reference. `design_spec.json` +is a receipt-like summary for audit and comparison; `slide_plan.json` remains +the protocol-facing plan. + The runner fingerprints source files, plan, catalogs, generated SVG, prepared SVG, and receipts. Source pack changes should invalidate receipts rather than letting old generation or quality evidence be reused silently. -`preview_lint`, `preflight`, `quality_gate`, `ppe_proof`, `dry_run`, -`live_create`, and `readback` are runner-owned stages. Do not override -`preview_lint` through `stage_commands`; the runner calls the bundled -`scripts/svg_preview_lint.py` with a fixed argument contract. +`source`, `strategy`, `preview_lint`, `preflight`, `chart_verify`, +`quality_gate`, `ppe_proof`, `dry_run`, `live_create`, and `readback` are +runner-owned stages. Do not override `preview_lint` through `stage_commands`; +the runner calls the bundled `scripts/svg_preview_lint.py` with a fixed +argument contract. ## Prepare @@ -231,13 +248,20 @@ quality lane. Manual debugging may continue from preflight to dry-run/readback without `preview/preview.html`, but it must not proceed to guarded live creation or production/golden delivery until preview lint and quality gate have passed. -`quality_gate` reads the latest preflight receipt, preview lint receipt, raster -report, allowlist, asset selection, visual design contract, and component report -evidence. If a slide declares `visual_design_contract.required_visual_evidence`, -the same page in `receipts/emitted_components.json` must prove those evidence -tokens through component `effects`, `primitives`, `renderer_id`, or component id. -During P0 migration, authoring/debug dry-run may use an unexpired legacy -component waiver. Production, golden, and live lanes must not use that waiver. +`chart_verify` reads `slide_plan.json` and `prepared/*.svg`. When a slide +declares a required `chart_decision`, it writes `receipts/chart-verify.json` +proving that the expected chart carrier exists in the prepared SVG. This first +pass checks visible geometry and anchors; stricter data-to-coordinate checks can +extend the same receipt. + +`quality_gate` reads the latest preflight receipt, preview lint receipt, +chart-verify receipt, raster report, allowlist, asset selection, visual design +contract, and component report evidence. If a slide declares +`visual_design_contract.required_visual_evidence`, the same page in +`receipts/emitted_components.json` must prove those evidence tokens through +component `effects`, `primitives`, `renderer_id`, or component id. During P0 +migration, authoring/debug dry-run may use an unexpired legacy component +waiver. Production, golden, and live lanes must not use that waiver. ## PPE Proof diff --git a/skills/lark-slides/references/svglide-renderer-registry.json b/skills/lark-slides/references/svglide-renderer-registry.json new file mode 100644 index 000000000..a342aa356 --- /dev/null +++ b/skills/lark-slides/references/svglide-renderer-registry.json @@ -0,0 +1,185 @@ +{ + "schema_version": "svglide-renderer-registry/v1", + "source": "SVGlide internal renderer contracts for create-svg authoring", + "rules": [ + "Active renderers must map to an existing layout seed and visual recipe.", + "Active renderers must declare the runtime renderer family used by svglide_gen_runtime.py.", + "Candidate renderers may document planned coverage, but strategist must not select them automatically." + ], + "renderers": [ + { + "id": "cover_hero_statement", + "status": "active", + "page_kind": "cover", + "runtime_renderer_family": "layout.cover", + "layout_seed_id": "cover_hero_statement", + "visual_recipe_id": "hero_typography", + "style_reskin_hooks": ["background", "hero_route", "title_field", "motif"], + "required_primitives": ["typography", "geometric_shape", "path"] + }, + { + "id": "agenda_numbered_path", + "status": "active", + "page_kind": "agenda", + "runtime_renderer_family": "chart.flow", + "layout_seed_id": "agenda_numbered_path", + "visual_recipe_id": "path_flow", + "style_reskin_hooks": ["numbered_route", "connector", "section_label"], + "required_primitives": ["typography", "path", "annotation"] + }, + { + "id": "section_divider_index", + "status": "active", + "page_kind": "section", + "runtime_renderer_family": "layout.chapter", + "layout_seed_id": "section_divider_index", + "visual_recipe_id": "gradient_depth", + "style_reskin_hooks": ["section_index", "signal_field", "motif"], + "required_primitives": ["typography", "gradient", "geometric_shape"] + }, + { + "id": "dashboard_kpi_grid", + "status": "active", + "page_kind": "kpi_cards", + "runtime_renderer_family": "chart.kpi", + "layout_seed_id": "dashboard_kpi_grid", + "visual_recipe_id": "fake_ui_dashboard", + "style_reskin_hooks": ["metric_card", "micro_trend", "insight_strip"], + "required_primitives": ["typography", "dashboard", "micro_chart"] + }, + { + "id": "single_chart_takeaway", + "status": "active", + "page_kind": "bar_chart", + "runtime_renderer_family": "chart.bar", + "layout_seed_id": "single_chart_takeaway", + "visual_recipe_id": "infographic_scorecard", + "chart_types": ["bar_chart", "horizontal_bar_chart", "pareto_chart"], + "style_reskin_hooks": ["plot_area", "bars", "axis_label", "insight_strip"], + "required_primitives": ["typography", "micro_chart", "geometric_shape"] + }, + { + "id": "timeline_roadmap", + "status": "active", + "page_kind": "timeline", + "runtime_renderer_family": "chart.flow", + "layout_seed_id": "timeline_roadmap", + "visual_recipe_id": "path_flow", + "style_reskin_hooks": ["phase_spine", "milestone", "ownership_label"], + "required_primitives": ["typography", "path", "annotation"] + }, + { + "id": "process_pipeline", + "status": "active", + "page_kind": "process_flow", + "runtime_renderer_family": "chart.flow", + "layout_seed_id": "process_pipeline", + "visual_recipe_id": "path_flow", + "style_reskin_hooks": ["flow_lane", "connector", "input_output_anchor"], + "required_primitives": ["typography", "path", "annotation"] + }, + { + "id": "comparison_two_column_decision", + "status": "active", + "page_kind": "comparison", + "runtime_renderer_family": "chart.matrix", + "layout_seed_id": "comparison_two_column_decision", + "visual_recipe_id": "geometric_composition", + "chart_types": ["comparison_table", "quadrant_text_bullets"], + "style_reskin_hooks": ["decision_axis", "contrast_panel", "dimension_label"], + "required_primitives": ["typography", "geometric_shape"] + }, + { + "id": "capability_icon_map", + "status": "active", + "page_kind": "hub_spoke", + "runtime_renderer_family": "chart.hub", + "layout_seed_id": "capability_icon_map", + "visual_recipe_id": "icon_capability_map", + "chart_types": ["hub_spoke"], + "style_reskin_hooks": ["hub", "spoke", "module_icon", "orbit"], + "required_primitives": ["typography", "icon", "geometric_shape"] + }, + { + "id": "spotlight_diagnosis_callout", + "status": "active", + "page_kind": "insight_callout", + "runtime_renderer_family": "contract.annotation", + "layout_seed_id": "spotlight_diagnosis_callout", + "visual_recipe_id": "spotlight_annotation", + "style_reskin_hooks": ["spotlight", "annotation_line", "callout_label"], + "required_primitives": ["typography", "spotlight", "annotation"] + }, + { + "id": "closing_summary", + "status": "active", + "page_kind": "closing", + "runtime_renderer_family": "layout.closing", + "layout_seed_id": "closing_summary", + "visual_recipe_id": "brand_system", + "style_reskin_hooks": ["closing_ribbon", "action_card", "final_statement"], + "required_primitives": ["typography", "geometric_shape"] + }, + { + "id": "bubble_chart", + "status": "active", + "page_kind": "bubble_chart", + "runtime_renderer_family": "chart.bubble", + "layout_seed_id": "single_chart_takeaway", + "visual_recipe_id": "infographic_scorecard", + "chart_types": ["bubble_chart"], + "style_reskin_hooks": ["plot_area", "bubble", "label_plate", "insight_band"], + "required_primitives": ["typography", "micro_chart", "geometric_shape"] + }, + { + "id": "donut_chart", + "status": "active", + "page_kind": "donut_chart", + "runtime_renderer_family": "chart.donut", + "layout_seed_id": "single_chart_takeaway", + "visual_recipe_id": "infographic_scorecard", + "chart_types": ["donut_chart"], + "style_reskin_hooks": ["ring", "segment_label", "center_metric", "legend"], + "required_primitives": ["typography", "micro_chart", "geometric_shape"] + }, + { + "id": "sankey_chart", + "status": "active", + "page_kind": "sankey_chart", + "runtime_renderer_family": "chart.sankey", + "layout_seed_id": "process_pipeline", + "visual_recipe_id": "path_flow", + "chart_types": ["sankey_chart"], + "style_reskin_hooks": ["flow_width", "source_node", "target_node", "return_path"], + "required_primitives": ["typography", "path", "geometric_shape"] + }, + { + "id": "line_chart", + "status": "candidate", + "page_kind": "line_chart", + "runtime_renderer_family": "chart.line", + "layout_seed_id": "single_chart_takeaway", + "visual_recipe_id": "infographic_scorecard", + "chart_types": ["dual_axis_line_chart"], + "activation_blocker": "needs line data-to-coordinate chart verification fixture" + }, + { + "id": "table_editorial", + "status": "candidate", + "page_kind": "table", + "runtime_renderer_family": "chart.table", + "layout_seed_id": "comparison_two_column_decision", + "visual_recipe_id": "geometric_composition", + "activation_blocker": "needs dense table preview lint fixture" + }, + { + "id": "regional_image_story", + "status": "candidate", + "page_kind": "image_story", + "runtime_renderer_family": "layout.content", + "layout_seed_id": "image_story_showcase", + "visual_recipe_id": "mask_clip_showcase", + "activation_blocker": "needs asset receipt lane for image-backed story pages" + } + ] +} diff --git a/skills/lark-slides/scripts/svg_preflight.py b/skills/lark-slides/scripts/svg_preflight.py index f69982ffe..6d487110f 100644 --- a/skills/lark-slides/scripts/svg_preflight.py +++ b/skills/lark-slides/scripts/svg_preflight.py @@ -3020,6 +3020,10 @@ def skeleton_drift_issues(seed_id: str, seed_data: dict[str, Any], plan_boxes: l def infer_text_box_role(text_box: dict[str, Any], available_roles: set[str]) -> str: identifier = textify(text_box.get("identifier") or text_box.get("element_id")) visible_text = textify(text_box.get("text")) + identifier_role = normalize_name(identifier) + for role in sorted(available_roles, key=len, reverse=True): + if role and role in identifier_role: + return role normalized = normalize_name(f"{identifier} {visible_text}") for role in sorted(available_roles, key=len, reverse=True): if role and role in normalized: diff --git a/skills/lark-slides/scripts/svg_preflight_test.py b/skills/lark-slides/scripts/svg_preflight_test.py index 0410f580d..8f0792ed3 100644 --- a/skills/lark-slides/scripts/svg_preflight_test.py +++ b/skills/lark-slides/scripts/svg_preflight_test.py @@ -342,6 +342,18 @@ def chart_marker(metadata: str) -> str: class SvgPreflightTest(unittest.TestCase): + def test_text_role_inference_prefers_identifier_over_visible_text(self) -> None: + role = svg_preflight.infer_text_box_role( + { + "identifier": "body", + "text": "Business health metrics and KPI status", + "bbox": {"x": 64, "y": 426, "width": 832, "height": 42}, + }, + {"title", "body", "metric", "grid", "footer"}, + ) + + self.assertEqual("body", role) + def test_lint_svg_accepts_valid_svglide(self) -> None: result = svg_preflight.lint_svg(VALID_SVG) self.assertEqual(result["summary"]["error_count"], 0) diff --git a/skills/lark-slides/scripts/svglide_golden_suite.py b/skills/lark-slides/scripts/svglide_golden_suite.py index a977eeeed..b4875f962 100644 --- a/skills/lark-slides/scripts/svglide_golden_suite.py +++ b/skills/lark-slides/scripts/svglide_golden_suite.py @@ -56,6 +56,28 @@ _GOLDEN_CASES: tuple[dict[str, Any], ...] = ( "svg_preflight_pass", ], }, + { + "case_id": "data-dense-business-report", + "theme_domain": "business_operations_data_report", + "prompt_summary": "Dense business report with executive summary, KPI dashboard, trend chart, comparison matrix, and action plan.", + "expected_archetypes": [ + "cover", + "agenda", + "kpi_cards", + "bar_chart", + "line_chart", + "comparison_table", + "process_flow", + "closing", + ], + "required_evidence": [ + "source_pack_digest", + "renderer_registry_mapping", + "chart_verify_receipt", + "quality_gate_pass", + "timing_receipt", + ], + }, { "case_id": "runtime-smoke", "theme_domain": "svglide_runtime_health", diff --git a/skills/lark-slides/scripts/svglide_golden_suite_test.py b/skills/lark-slides/scripts/svglide_golden_suite_test.py index 1bbbc0ac4..15d96a4f0 100644 --- a/skills/lark-slides/scripts/svglide_golden_suite_test.py +++ b/skills/lark-slides/scripts/svglide_golden_suite_test.py @@ -32,6 +32,7 @@ class SVGlideGoldenSuiteTest(unittest.TestCase): { "ai-capital-editorial", "aksu-oasis-planning", + "data-dense-business-report", "runtime-smoke", }.issubset(case_ids) ) @@ -73,6 +74,14 @@ class SVGlideGoldenSuiteTest(unittest.TestCase): self.assertIn("agenda_numbered_path", aksu["required_evidence"]) self.assertIn("section_signal", aksu["required_evidence"]) + def test_data_dense_case_requires_runner_quality_evidence(self) -> None: + cases = {case["case_id"]: case for case in golden_suite.list_cases()} + data_case = cases["data-dense-business-report"] + + self.assertIn("chart_verify_receipt", data_case["required_evidence"]) + self.assertIn("quality_gate_pass", data_case["required_evidence"]) + self.assertIn("timing_receipt", data_case["required_evidence"]) + if __name__ == "__main__": unittest.main() diff --git a/skills/lark-slides/scripts/svglide_project_runner.py b/skills/lark-slides/scripts/svglide_project_runner.py index fd70d001d..259c41f3f 100644 --- a/skills/lark-slides/scripts/svglide_project_runner.py +++ b/skills/lark-slides/scripts/svglide_project_runner.py @@ -26,11 +26,14 @@ import svg_preflight STAGES = [ + "source", + "strategy", "generate", "prepare", "preview", "preflight", "preview_lint", + "chart_verify", "quality_gate", "dry_run", "ppe_proof", @@ -39,12 +42,18 @@ STAGES = [ "render_contact_sheet", ] STAGE_ALIASES = { + "source-pack": "source", + "source_pack": "source", + "design-strategy": "strategy", + "design_strategy": "strategy", "dry-run": "dry_run", "dry_run": "dry_run", "live-create": "live_create", "live_create": "live_create", "preview-lint": "preview_lint", "preview_lint": "preview_lint", + "chart-verify": "chart_verify", + "chart_verify": "chart_verify", "quality-gate": "quality_gate", "quality_gate": "quality_gate", "ppe-proof": "ppe_proof", @@ -53,11 +62,14 @@ STAGE_ALIASES = { "render_contact_sheet": "render_contact_sheet", } STAGE_TARGET_MS = { + "source": 3_000, + "strategy": 5_000, "generate": 30_000, "prepare": 5_000, "preview": 10_000, "preflight": 10_000, "preview_lint": 10_000, + "chart_verify": 5_000, "quality_gate": 3_000, "dry_run": 30_000, "ppe_proof": 30_000, @@ -72,6 +84,9 @@ QUALITY_GATE_SCHEMA = "svglide-quality-gate/v1" ENV_PROOF_SCHEMA = "svglide-env-proof/v1" DESIGN_PATTERN_USAGE_SCHEMA = "svglide-design-pattern-usage/v1" COMPONENT_REPORT_SCHEMA = "svglide-component-report/v1" +CHART_VERIFY_SCHEMA = "svglide-chart-verify/v1" +SOURCE_PACK_SCHEMA = "svglide-source-pack/v1" +DESIGN_SPEC_SCHEMA = "svglide-design-spec/v1" PREVIEW_LINT_WAIVER_TTL_MS = 30 * 60 * 1000 DEFAULT_VALIDATION_PROFILE = "authoring" STRATEGIST_CONTRACT_CODES = { @@ -524,11 +539,217 @@ def project_brief_text(project: Path, data: dict[str, Any]) -> str: continue path = safe_existing_file(project_file(project, value), root=project) parts.append(path.read_text(encoding="utf-8")) + has_explicit_brief = bool(parts) for relative in ["source/brief.md", "source/prompt.md", "brief.md", "prompt.md"]: + if has_explicit_brief and relative == "source/brief.md": + continue candidate = project / relative if candidate.exists() and candidate.is_file(): parts.append(candidate.read_text(encoding="utf-8")) - return "\n\n".join(part.strip() for part in parts if part.strip()) + normalized: list[str] = [] + seen: set[str] = set() + for part in parts: + stripped = part.strip() + if not stripped or stripped in seen: + continue + seen.add(stripped) + normalized.append(stripped) + return "\n\n".join(normalized) + + +def source_dir(project: Path) -> Path: + return project / "source" + + +def source_pack_path(project: Path) -> Path: + return source_dir(project) / "source_pack.json" + + +def evidence_path(project: Path) -> Path: + return source_dir(project) / "evidence.json" + + +def source_brief_path(project: Path) -> Path: + return source_dir(project) / "brief.md" + + +def design_spec_path(project: Path) -> Path: + return source_dir(project) / "design_spec.json" + + +def normalize_source_pack(pack: Any, brief: str, data: dict[str, Any]) -> dict[str, Any]: + if isinstance(pack, dict): + out = dict(pack) + else: + out = {} + out.setdefault("schema_version", SOURCE_PACK_SCHEMA) + out.setdefault("source_status", "user_prompt_only" if brief.strip() else "missing") + out.setdefault("numeric_claim_policy", "cite_or_remove") + items = out.get("items") + if not isinstance(items, list): + items = [] + has_brief = any(isinstance(item, dict) and text_from_any(item.get("id")) == "brief" for item in items) + if not has_brief: + items.insert( + 0, + { + "id": "brief", + "type": "user_prompt", + "status": "available" if brief.strip() else "missing", + "source_ref": "source/brief.md", + "usage_pages": "all", + "license": "user_provided", + }, + ) + manifest_sources = data.get("source") if isinstance(data.get("source"), dict) else {} + for key in ["research", "evidence", "source_pack"]: + value = text_from_any(manifest_sources.get(key)) if isinstance(manifest_sources, dict) else "" + if value and not any(isinstance(item, dict) and text_from_any(item.get("source_ref")) == value for item in items): + items.append( + { + "id": f"manifest_{key}", + "type": key, + "status": "declared", + "source_ref": value, + "usage_pages": "all", + "license": "project_manifest", + } + ) + out["items"] = items + return out + + +def build_source_artifacts(project: Path, data: dict[str, Any], plan: dict[str, Any] | None = None) -> dict[str, Any]: + brief = project_brief_text(project, data) + existing_pack = plan.get("source_pack") if isinstance(plan, dict) else None + pack = normalize_source_pack(existing_pack, brief, data) + evidence = { + "schema_version": "svglide-evidence-index/v1", + "source_pack": "source/source_pack.json", + "brief": "source/brief.md", + "source_status": pack.get("source_status"), + "items": [ + { + "id": text_from_any(item.get("id")), + "source_ref": text_from_any(item.get("source_ref")), + "status": text_from_any(item.get("status")), + "type": text_from_any(item.get("type")), + } + for item in pack.get("items", []) + if isinstance(item, dict) + ], + } + return {"brief": brief, "source_pack": pack, "evidence": evidence} + + +def write_source_artifacts(project: Path, artifacts: dict[str, Any]) -> dict[str, Any]: + source_dir(project).mkdir(parents=True, exist_ok=True) + brief = text_from_any(artifacts.get("brief")) + if brief: + source_brief_path(project).write_text(brief.rstrip() + "\n", encoding="utf-8") + elif not source_brief_path(project).exists(): + source_brief_path(project).write_text("", encoding="utf-8") + write_json(source_pack_path(project), artifacts.get("source_pack", {})) + write_json(evidence_path(project), artifacts.get("evidence", {})) + pack = artifacts.get("source_pack") if isinstance(artifacts.get("source_pack"), dict) else {} + return { + "status": "passed", + "source_pack": rel_to_project(project, source_pack_path(project)), + "evidence": rel_to_project(project, evidence_path(project)), + "brief": rel_to_project(project, source_brief_path(project)), + "source_pack_digest": json_digest(pack), + "source_pack_status": text_from_any(pack.get("source_status")) or "missing", + "item_count": len(pack.get("items", [])) if isinstance(pack.get("items"), list) else 0, + } + + +def design_spec_from_plan(plan: dict[str, Any], project: Path) -> dict[str, Any]: + slides = plan.get("slides") if isinstance(plan.get("slides"), list) else [] + renderer_selection: list[dict[str, Any]] = [] + for index, slide in enumerate(slides, 1): + if not isinstance(slide, dict): + continue + renderer_selection.append( + { + "page": slide.get("page", index), + "page_type": text_from_any(slide.get("page_type")), + "renderer_id": text_from_any(slide.get("renderer_id")), + "runtime_renderer_family": text_from_any(slide.get("runtime_renderer_family")), + "seed_id": text_from_any(slide.get("seed_id")), + "visual_recipe": text_from_any(slide.get("visual_recipe")), + "visual_signature": text_from_any(slide.get("visual_signature")), + "reference_asset": slide.get("reference_asset") if isinstance(slide.get("reference_asset"), dict) else {}, + "chart_decision": slide.get("chart_decision") if isinstance(slide.get("chart_decision"), dict) else {}, + } + ) + return { + "schema_version": DESIGN_SPEC_SCHEMA, + "title": deck_title(project, {"title": plan.get("title") or plan.get("deck_title") or project.name}), + "output_mode": text_from_any(plan.get("output_mode")), + "mode": text_from_any(plan.get("mode") or plan.get("narrative_mode")), + "visual_style": text_from_any(plan.get("visual_style")), + "style_preset": text_from_any(plan.get("style_preset")), + "style_system": plan.get("style_system") if isinstance(plan.get("style_system"), dict) else {}, + "strategy_locks": plan.get("strategy_locks") if isinstance(plan.get("strategy_locks"), list) else [], + "asset_strategy": plan.get("asset_strategy") if isinstance(plan.get("asset_strategy"), dict) else {}, + "chart_policy": plan.get("chart_policy") if isinstance(plan.get("chart_policy"), dict) else {}, + "icon_policy": plan.get("icon_policy") if isinstance(plan.get("icon_policy"), dict) else {}, + "page_rhythm": plan.get("page_rhythm") if isinstance(plan.get("page_rhythm"), list) else [], + "source_pack_ref": "source/source_pack.json", + "renderer_registry_ref": "skills/lark-slides/references/svglide-renderer-registry.json", + "renderer_selection": renderer_selection, + } + + +def write_design_spec(project: Path, plan: dict[str, Any]) -> dict[str, Any]: + spec = design_spec_from_plan(plan, project) + write_json(design_spec_path(project), spec) + return { + "design_spec": rel_to_project(project, design_spec_path(project)), + "design_spec_digest": json_digest(spec), + "renderer_count": len(spec.get("renderer_selection", [])) if isinstance(spec.get("renderer_selection"), list) else 0, + "visual_style": spec.get("visual_style"), + "style_preset": spec.get("style_preset"), + } + + +def run_source(project: Path, data: dict[str, Any]) -> dict[str, Any]: + artifacts = build_source_artifacts(project, data, {}) + return write_source_artifacts(project, artifacts) + + +def run_strategy(project: Path, data: dict[str, Any]) -> dict[str, Any]: + import svglide_strategist + + plan_path = safe_output_file(plan_file(project, data), root=project, suffix=".json") + existing_plan = read_json(plan_path, {}) if plan_path.exists() else None + if existing_plan is not None and not isinstance(existing_plan, dict): + raise RunnerError("slide_plan.json must contain an object") + contract = svglide_strategist.build_contract( + brief=project_brief_text(project, data), + slide_plan=existing_plan, + page_descriptions=manifest_page_descriptions(data), + ) + if not text_from_any(contract.get("title")): + contract["title"] = deck_title(project, data) + source_artifacts = build_source_artifacts(project, data, contract) + write_source_artifacts(project, source_artifacts) + contract["source_pack"] = source_artifacts["source_pack"] + contract["source_pack_ref"] = "source/source_pack.json" + contract["design_spec_ref"] = "source/design_spec.json" + write_json(plan_path, contract) + spec_summary = write_design_spec(project, contract) + return { + "status": "passed", + "plan": rel_to_project(project, plan_path), + "source_pack": "source/source_pack.json", + **spec_summary, + "strategy_lock_ids": [ + text_from_any(item.get("id")) + for item in contract.get("strategy_locks", []) + if isinstance(item, dict) and text_from_any(item.get("id")) + ], + } def builtin_generate(project: Path, data: dict[str, Any]) -> dict[str, Any]: @@ -549,13 +770,21 @@ def builtin_generate(project: Path, data: dict[str, Any]) -> dict[str, Any]: ) if not text_from_any(contract.get("title")): contract["title"] = deck_title(project, data) + source_artifacts = build_source_artifacts(project, data, contract) + write_source_artifacts(project, source_artifacts) + contract["source_pack"] = source_artifacts["source_pack"] + contract["source_pack_ref"] = "source/source_pack.json" + contract["design_spec_ref"] = "source/design_spec.json" write_json(plan_path, contract) + spec_summary = write_design_spec(project, contract) runtime_cache = svglide_gen_runtime.compose_project(project, plan_path) body = { "status": "passed", "generator": "builtin:svglide_strategist_runtime", "plan": rel_to_project(project, plan_path), + "source_pack": "source/source_pack.json", + **spec_summary, "page_count": runtime_cache.get("page_count", 0), "outputs": runtime_cache.get("outputs", {}), "runtime_cache": runtime_cache, @@ -714,6 +943,25 @@ def stage_input_fingerprint(stage: str, project: Path, args: argparse.Namespace) add_source_pack_fingerprints(inputs, project, data) add_value_fingerprint(inputs, "stage_command", stage_command(data, stage)) + if stage == "source": + for relative in ["source/brief.md", "source/prompt.md", "brief.md", "prompt.md"]: + add_file_fingerprint(inputs, project, "brief", project / relative) + add_value_fingerprint(inputs, "brief", project_brief_text(project, data)) + add_value_fingerprint(inputs, "page_descriptions", manifest_page_descriptions(data)) + if stage == "strategy": + add_file_fingerprint(inputs, project, "source_receipt", receipt_path(project, "source", data)) + add_file_fingerprint(inputs, project, "source_pack", source_pack_path(project)) + add_file_fingerprint(inputs, project, "evidence", evidence_path(project)) + plan = plan_file(project, data) + if plan.exists(): + add_file_fingerprint(inputs, project, "plan", plan) + scripts = repo_root() / "skills" / "lark-slides" / "scripts" + references = repo_root() / "skills" / "lark-slides" / "references" + add_file_fingerprint(inputs, project, "script", scripts / "svglide_strategist.py") + for catalog in ["style-presets.json", "svg-seeds.json", "svg-recipes.json", "svglide-design-pattern-map.json", "svglide-renderer-registry.json"]: + add_file_fingerprint(inputs, project, "catalog", references / catalog) + add_value_fingerprint(inputs, "brief", project_brief_text(project, data)) + add_value_fingerprint(inputs, "page_descriptions", manifest_page_descriptions(data)) if stage == "generate": plan = plan_file(project, data) if plan.exists(): @@ -724,17 +972,18 @@ def stage_input_fingerprint(stage: str, project: Path, args: argparse.Namespace) references = repo_root() / "skills" / "lark-slides" / "references" for script_name in ["svglide_strategist.py", "svglide_gen_runtime.py"]: add_file_fingerprint(inputs, project, "script", scripts / script_name) - for catalog in ["style-presets.json", "svg-seeds.json", "svg-recipes.json", "svglide-design-pattern-map.json"]: + for catalog in ["style-presets.json", "svg-seeds.json", "svg-recipes.json", "svglide-design-pattern-map.json", "svglide-renderer-registry.json"]: add_file_fingerprint(inputs, project, "catalog", references / catalog) + add_file_fingerprint(inputs, project, "design_spec", design_spec_path(project)) add_value_fingerprint(inputs, "brief", project_brief_text(project, data)) add_value_fingerprint(inputs, "page_descriptions", manifest_page_descriptions(data)) - if stage in {"generate", "prepare"}: + if stage in {"source", "strategy", "generate", "prepare"}: for page in project_pages(project, data, prepared=False): add_file_fingerprint(inputs, project, "source_svg", page) - if stage not in {"generate", "prepare"}: + if stage not in {"source", "strategy", "generate", "prepare"}: for page in project_pages(project, data, prepared=True): add_file_fingerprint(inputs, project, "prepared_svg", page) - if stage != "generate": + if stage not in {"source"}: add_file_fingerprint(inputs, project, "plan", plan_file(project, data)) scripts = repo_root() / "skills" / "lark-slides" / "scripts" @@ -753,11 +1002,16 @@ def stage_input_fingerprint(stage: str, project: Path, args: argparse.Namespace) add_file_fingerprint(inputs, project, "script", scripts / "svg_preview_lint.py") add_value_fingerprint(inputs, "allow_missing_preview_lint", bool(get_arg(args, "allow_missing_preview_lint", False))) add_value_fingerprint(inputs, "validation_profile", quality_validation_profile(resolved_validation_profile(data, args, project=project))) + if stage == "chart_verify": + add_file_fingerprint(inputs, project, "script", scripts / "svglide_project_runner.py") + add_file_fingerprint(inputs, project, "plan", plan_file(project, data)) + add_file_fingerprint(inputs, project, "preflight_receipt", receipt_path(project, "preflight", data)) if stage == "quality_gate": - for dependency in ["preflight", "preview_lint"]: + for dependency in ["preflight", "preview_lint", "chart_verify"]: add_file_fingerprint(inputs, project, f"{dependency}_receipt", receipt_path(project, dependency, data)) add_file_fingerprint(inputs, project, "component_report", project / "receipts" / "emitted_components.json") add_file_fingerprint(inputs, project, "design_pattern_usage", project / "receipts" / "design-pattern-usage.json") + add_file_fingerprint(inputs, project, "design_spec", design_spec_path(project)) add_file_fingerprint(inputs, project, "component_waiver", project / "receipts" / "emitted-components-waiver.json") add_file_fingerprint(inputs, project, "allowlist_receipt", project / "receipts" / "allowlist.json") try: @@ -1991,6 +2245,209 @@ def raster_summary(project: Path, data: dict[str, Any]) -> dict[str, Any]: } +def chart_requirement_slides(project: Path, data: dict[str, Any]) -> list[dict[str, Any]]: + plan = read_json(plan_file(project, data), {}) + if not isinstance(plan, dict): + return [] + slides = plan.get("slides") + if not isinstance(slides, list): + return [] + requirements: list[dict[str, Any]] = [] + for index, slide in enumerate(slides, 1): + if not isinstance(slide, dict): + continue + decision = slide.get("chart_decision") if isinstance(slide.get("chart_decision"), dict) else {} + chart_type = text_from_any(decision.get("chart_type") or slide.get("chart_type")) + status = text_from_any(decision.get("status")) + if not chart_type or status == "not_required": + continue + try: + page_number = int(slide.get("page", index)) + except (TypeError, ValueError): + page_number = index + requirements.append( + { + "page": page_number if not isinstance(page_number, bool) else index, + "index": index, + "chart_type": chart_type, + "anchor_role": text_from_any(decision.get("anchor_role")), + "data_ref": text_from_any(decision.get("data_ref")), + "reason": text_from_any(decision.get("reason")), + } + ) + return requirements + + +def chart_mark_kind(chart_type: str) -> str: + normalized = chart_type.replace("-", "_").lower() + if "bar" in normalized or normalized in {"pareto_chart", "waterfall_chart"}: + return "bar" + if "line" in normalized or "area" in normalized: + return "line" + if "donut" in normalized or "pie" in normalized: + return "donut" + if "bubble" in normalized or "scatter" in normalized: + return "bubble" + if "sankey" in normalized or "flow" in normalized: + return "sankey" + if "kpi" in normalized: + return "kpi" + if "hub" in normalized: + return "hub" + if "table" in normalized or "matrix" in normalized: + return "table" + return "chart" + + +def element_chart_tokens(element: ET.Element) -> set[str]: + name = svg_preflight.local_name(element.tag).lower() + identifier = svg_preflight.element_identifier_text(element).lower() + text = "".join(element.itertext()).lower() + combined = f"{identifier} {text}" + tokens: set[str] = set() + if re.search(r"(chart|plot|axis|insight|metric|kpi)", combined): + tokens.add("chart") + if name == "rect" and re.search(r"(bar|column|rank)", combined): + tokens.add("bar") + if name in {"path", "line", "polyline"} and re.search(r"(line|trend|curve|axis)", combined): + tokens.add("line") + if re.search(r"(donut|ring|segment|share|proportion)", combined): + tokens.add("donut") + if name in {"circle", "ellipse"} and re.search(r"(bubble|scatter|node)", combined): + tokens.add("bubble") + if name in {"path", "rect"} and re.search(r"(sankey|flow|lane|stream)", combined): + tokens.add("sankey") + if re.search(r"(hub|spoke|orbit|node)", combined): + tokens.add("hub") + if re.search(r"(table|matrix|cell|row|column)", combined): + tokens.add("table") + if name == "foreignobject" and re.search(r"(metric|kpi|%|\\$|\\d)", combined): + tokens.add("kpi") + return tokens + + +def chart_mark_summary(path: Path) -> dict[str, Any]: + root = ET.parse(path).getroot() + counts: dict[str, int] = {} + ids: list[str] = [] + for element in root.iter(): + if svg_preflight.svg_role(element) not in {"shape", "image"}: + continue + tokens = element_chart_tokens(element) + if not tokens: + continue + identifier = svg_preflight.element_identifier_text(element) + if identifier: + ids.append(identifier) + for token in tokens: + counts[token] = counts.get(token, 0) + 1 + return {"counts": counts, "ids": ids[:24]} + + +def chart_kind_passes(kind: str, counts: dict[str, int]) -> bool: + if kind == "bar": + return counts.get("bar", 0) >= 2 or counts.get("chart", 0) >= 3 + if kind == "line": + return counts.get("line", 0) >= 1 or counts.get("chart", 0) >= 3 + if kind == "donut": + return counts.get("donut", 0) >= 2 or counts.get("chart", 0) >= 3 + if kind == "bubble": + return counts.get("bubble", 0) >= 2 or counts.get("chart", 0) >= 3 + if kind == "sankey": + return counts.get("sankey", 0) >= 2 or counts.get("chart", 0) >= 3 + if kind == "kpi": + return counts.get("kpi", 0) >= 2 or counts.get("chart", 0) >= 2 + if kind in {"hub", "table"}: + return counts.get(kind, 0) >= 2 or counts.get("chart", 0) >= 2 + return counts.get("chart", 0) >= 2 + + +def run_chart_verify(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]: + require_fresh_receipt(project, data, "preflight", {"passed"}, args) + requirements = chart_requirement_slides(project, data) + prepared_pages = project_pages(project, data, prepared=True) + pages: list[dict[str, Any]] = [] + issues: list[dict[str, Any]] = [] + for requirement in requirements: + page_index = int(requirement["index"]) - 1 + chart_type = requirement["chart_type"] + kind = chart_mark_kind(chart_type) + if page_index < 0 or page_index >= len(prepared_pages): + issues.append({"level": "error", "code": "missing_prepared_page", "page": requirement["page"], "chart_type": chart_type}) + continue + page_path = safe_existing_file(prepared_pages[page_index], suffix=".svg", root=project) + try: + marks = chart_mark_summary(page_path) + except ET.ParseError as error: + issues.append({"level": "error", "code": "invalid_svg", "page": requirement["page"], "message": str(error)}) + continue + counts = marks["counts"] + passed = chart_kind_passes(kind, counts) + if not passed: + issues.append( + { + "level": "error", + "code": "chart_geometry_not_found", + "page": requirement["page"], + "chart_type": chart_type, + "expected_mark_kind": kind, + "counts": counts, + } + ) + if not requirement.get("data_ref"): + issues.append({"level": "warning", "code": "chart_data_ref_missing", "page": requirement["page"], "chart_type": chart_type}) + pages.append( + { + "page": requirement["page"], + "chart_type": chart_type, + "expected_mark_kind": kind, + "status": "passed" if passed else "failed", + "prepared_svg": rel_to_project(project, page_path), + "mark_counts": counts, + "sample_ids": marks["ids"], + "anchor_role": requirement.get("anchor_role"), + "data_ref": requirement.get("data_ref"), + } + ) + + error_count = sum(1 for issue in issues if issue.get("level") == "error") + warning_count = sum(1 for issue in issues if issue.get("level") == "warning") + return { + "schema_version": CHART_VERIFY_SCHEMA, + "status": "passed" if error_count == 0 else "failed", + "summary": { + "required_chart_count": len(requirements), + "verified_chart_count": sum(1 for page in pages if page.get("status") == "passed"), + "error_count": error_count, + "warning_count": warning_count, + }, + "pages": pages, + "issues": issues, + } + + +def chart_verify_summary(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]: + requirements = chart_requirement_slides(project, data) + if not requirements and not receipt_path(project, "chart_verify", data).exists(): + return { + "status": "not_required", + "required_chart_count": 0, + "verified_chart_count": 0, + "error_count": 0, + "warning_count": 0, + } + receipt = require_fresh_receipt(project, data, "chart_verify", {"passed"}, args) + summary = receipt.get("summary") if isinstance(receipt.get("summary"), dict) else {} + return { + "status": text_from_any(receipt.get("status")) or "passed", + "required_chart_count": required_int(summary.get("required_chart_count", 0), "chart_verify.summary.required_chart_count"), + "verified_chart_count": required_int(summary.get("verified_chart_count", 0), "chart_verify.summary.verified_chart_count"), + "error_count": required_int(summary.get("error_count", 0), "chart_verify.summary.error_count"), + "warning_count": required_int(summary.get("warning_count", 0), "chart_verify.summary.warning_count"), + "path": rel_to_project(project, receipt_path(project, "chart_verify", data)), + } + + def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]: preflight = require_fresh_receipt(project, data, "preflight", {"passed"}, args) preview_lint = require_fresh_receipt(project, data, "preview_lint", {"passed", "waived"}, args) @@ -2003,6 +2460,7 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa component_counts = component_report_summary(project, data, args) visual_contract = visual_design_contract_summary(project, data, args) design_usage = design_pattern_usage_summary(project, data, args) + chart_verify = chart_verify_summary(project, data, args) raster_counts = raster_summary(project, data) allowlist = allowlist_summary(project, data) profile = quality_validation_profile(resolved_validation_profile(data, args, project=project)) @@ -2031,6 +2489,8 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa failures.append("SVGlide visual design contract must be proven") if design_usage["error_count"] != 0: failures.append("SVGlide design pattern usage must be proven") + if chart_verify["error_count"] != 0: + failures.append("chart_verify.error_count must be 0") if raster_counts["error_count"] != 0: failures.append("raster.error_count must be 0") preview_score = preview_counts.get("visual_score") @@ -2049,6 +2509,7 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa + component_counts["warning_count"] + visual_contract["warning_count"] + design_usage["warning_count"] + + chart_verify["warning_count"] + raster_counts["warning_count"] ) if production or profile == "golden": @@ -2084,6 +2545,7 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa "threshold": threshold, "source_pack_status": generation_summary.get("source_pack_status"), "chart_alignment_status": chart_alignment_status, + "chart_verify_status": chart_verify.get("status"), } return { "schema_version": QUALITY_GATE_SCHEMA, @@ -2094,6 +2556,7 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa "component_report": component_counts, "visual_design_contract": visual_contract, "design_pattern_usage": design_usage, + "chart_verify": chart_verify, "raster": raster_counts, "allowlist": allowlist, "validation_profile": profile, @@ -2965,6 +3428,12 @@ def run_preview_lint(project: Path, data: dict[str, Any], args: argparse.Namespa def execute_stage(stage: str, project: Path, data: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: started = now_ms() ensure_project_dirs(project) + if stage == "source": + body = run_source(project, data) + return write_stage_receipt(project, data, stage, started, body, prepared_digest=False, args=args) + if stage == "strategy": + body = run_strategy(project, data) + return write_stage_receipt(project, data, stage, started, body, prepared_digest=False, args=args) if stage == "generate": body = run_generate(project, data) return write_stage_receipt(project, data, stage, started, body, prepared_digest=False, args=args) @@ -2981,6 +3450,9 @@ def execute_stage(stage: str, project: Path, data: dict[str, Any], args: argpars if stage == "preview_lint": body = run_preview_lint(project, data, args) return write_stage_receipt(project, data, stage, started, body, prepared_digest=True, args=args) + if stage == "chart_verify": + body = run_chart_verify(project, data, args) + return write_stage_receipt(project, data, stage, started, body, prepared_digest=True, args=args) if stage == "quality_gate": body = run_quality_gate(project, data, args) return write_stage_receipt(project, data, stage, started, body, prepared_digest=True, args=args) diff --git a/skills/lark-slides/scripts/svglide_project_runner_test.py b/skills/lark-slides/scripts/svglide_project_runner_test.py index deb441f2f..7849d1097 100644 --- a/skills/lark-slides/scripts/svglide_project_runner_test.py +++ b/skills/lark-slides/scripts/svglide_project_runner_test.py @@ -102,6 +102,8 @@ class SVGlideProjectRunnerTest(unittest.TestCase): self.assertEqual("passed", receipt["status"]) self.assertEqual("builtin:svglide_strategist_runtime", receipt["generator"]) self.assertTrue((project / "slide_plan.json").exists()) + self.assertTrue((project / "source" / "source_pack.json").exists()) + self.assertTrue((project / "source" / "design_spec.json").exists()) self.assertTrue((project / "pages" / "page-001.svg").exists()) self.assertTrue((project / "receipts" / "emitted_components.json").exists()) self.assertTrue((project / "receipts" / "design-pattern-usage.json").exists()) @@ -111,9 +113,59 @@ class SVGlideProjectRunnerTest(unittest.TestCase): plan = runner.read_json(project / "slide_plan.json", {}) self.assertEqual(3, plan["page_count"]) self.assertIn("design_pattern_selection", plan) + self.assertEqual("source/source_pack.json", plan["source_pack_ref"]) + self.assertEqual("source/design_spec.json", plan["design_spec_ref"]) self.assertEqual("briefing", plan["mode"]) self.assertEqual({"error_count": 0, "warning_count": 0}, svg_preflight.lint_plan(plan)["summary"]) + def test_source_and_strategy_stages_emit_project_receipts(self) -> None: + root = Path(tempfile.mkdtemp(dir=runner.repo_root())) + self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True)) + project = root / "strategy-project" + project.mkdir(parents=True) + write_json( + project / "project_manifest.json", + { + "deck_id": "strategy-project", + "title": "Strategy Project", + "plan": "slide_plan.json", + "brief": "Create an Aksu oasis planning deck with agenda, section, seasonal blocks, and value outlook.", + "page_descriptions": [ + "Cover: 以水为脉", + "目录:项目核心定位、春夏秋冬、价值展望", + "章节过渡页:01 项目核心定位与愿景", + ], + }, + ) + data = runner.manifest(project) + args = self.args(project) + + source = runner.execute_stage("source", project, data, args) + strategy = runner.execute_stage("strategy", project, data, args) + + self.assertEqual("passed", source["status"]) + self.assertEqual("passed", strategy["status"]) + self.assertTrue((project / "source" / "source_pack.json").exists()) + self.assertTrue((project / "source" / "evidence.json").exists()) + self.assertTrue((project / "source" / "design_spec.json").exists()) + plan = runner.read_json(project / "slide_plan.json", {}) + design_spec = runner.read_json(project / "source" / "design_spec.json", {}) + self.assertEqual("premium_regional", plan["visual_style"]) + self.assertEqual("source/source_pack.json", plan["source_pack_ref"]) + self.assertEqual("source/design_spec.json", plan["design_spec_ref"]) + self.assertEqual(3, len(design_spec["renderer_selection"])) + self.assertTrue(all(item["renderer_id"] for item in design_spec["renderer_selection"])) + + def test_project_brief_text_does_not_append_generated_source_brief_to_manifest_brief(self) -> None: + project = self.make_project() + data = runner.manifest(project) + data["brief"] = "Create a concise business report." + write_json(project / "project_manifest.json", data) + (project / "source").mkdir(exist_ok=True) + (project / "source" / "brief.md").write_text("Create a concise business report.\n", encoding="utf-8") + + self.assertEqual("Create a concise business report.", runner.project_brief_text(project, runner.manifest(project))) + def write_gate_inputs( self, project: Path, @@ -146,6 +198,8 @@ class SVGlideProjectRunnerTest(unittest.TestCase): }, args=args, ) + chart_verify = runner.run_chart_verify(project, data, args) + runner.write_stage_receipt(project, data, "chart_verify", runner.now_ms(), chart_verify, args=args) runner.write_stage_receipt( project, data, @@ -437,23 +491,30 @@ class SVGlideProjectRunnerTest(unittest.TestCase): self.assertFalse(runner.load_env_proof(str(proof))["verified"]) def test_stage_aliases_accept_cli_names(self) -> None: + self.assertEqual(runner.normalize_stage("source-pack"), "source") + self.assertEqual(runner.normalize_stage("design-strategy"), "strategy") self.assertEqual(runner.normalize_stage("dry-run"), "dry_run") self.assertEqual(runner.normalize_stage("live-create"), "live_create") self.assertEqual(runner.normalize_stage("preview-lint"), "preview_lint") + self.assertEqual(runner.normalize_stage("chart-verify"), "chart_verify") self.assertEqual(runner.normalize_stage("quality-gate"), "quality_gate") self.assertEqual(runner.normalize_stage("ppe-proof"), "ppe_proof") self.assertEqual(runner.normalize_stage("render-contact-sheet"), "render_contact_sheet") - def test_stage_graph_includes_quality_gate_and_ppe_proof(self) -> None: + def test_stage_graph_includes_source_strategy_chart_verify_quality_gate_and_ppe_proof(self) -> None: dry_run_stages = runner.stages_until("dry_run") readback_stages = runner.stages_until("readback") + self.assertEqual(dry_run_stages[:3], ["source", "strategy", "generate"]) + self.assertLess(dry_run_stages.index("preview_lint"), dry_run_stages.index("chart_verify")) + self.assertLess(dry_run_stages.index("chart_verify"), dry_run_stages.index("quality_gate")) self.assertIn("quality_gate", dry_run_stages) self.assertNotIn("ppe_proof", dry_run_stages) self.assertNotIn("live_create", dry_run_stages) self.assertEqual(readback_stages[-3:], ["ppe_proof", "live_create", "readback"]) parser = runner.build_parser() self.assertEqual(parser.parse_args(["quality-gate", "--project", "/tmp/p"]).single_stage, "quality_gate") + self.assertEqual(parser.parse_args(["chart-verify", "--project", "/tmp/p"]).single_stage, "chart_verify") self.assertEqual(parser.parse_args(["ppe-proof", "--project", "/tmp/p"]).single_stage, "ppe_proof") def test_skipped_preview_lint_receipt_is_not_reused(self) -> None: @@ -493,6 +554,87 @@ class SVGlideProjectRunnerTest(unittest.TestCase): with self.assertRaisesRegex(runner.RunnerError, "preview_lint is runner-owned"): runner.run_preview_lint(project, data, self.args(project)) + def write_minimal_preflight_receipt(self, project: Path, data: dict[str, object], args: Namespace) -> None: + self.write_prepare_receipt(project, data) + runner.write_stage_receipt( + project, + data, + "preflight", + runner.now_ms(), + { + "status": "passed", + "summary": { + "summary": {"error_count": 0, "warning_count": 0}, + "plan": {"issues": [], "summary": {"error_count": 0, "warning_count": 0}}, + }, + }, + args=args, + ) + + def test_chart_verify_passes_when_required_chart_has_svg_geometry(self) -> None: + project = self.make_project() + (project / "pages" / "page-001.svg").write_text(CHART_SVG, encoding="utf-8") + data = runner.manifest(project) + write_json( + project / "slide_plan.json", + { + "output_mode": "svglide-svg", + "slides": [ + { + "page": 1, + "chart_type": "bar_chart", + "chart_decision": { + "status": "required", + "chart_type": "bar_chart", + "reason": "compare category values", + "data_ref": "brief", + "anchor_role": "chart", + }, + } + ], + }, + ) + args = self.args(project) + self.write_minimal_preflight_receipt(project, data, args) + + body = runner.run_chart_verify(project, data, args) + + self.assertEqual("passed", body["status"]) + self.assertEqual(1, body["summary"]["required_chart_count"]) + self.assertEqual(1, body["summary"]["verified_chart_count"]) + self.assertEqual("bar", body["pages"][0]["expected_mark_kind"]) + + def test_chart_verify_fails_when_required_chart_has_no_svg_geometry(self) -> None: + project = self.make_project() + data = runner.manifest(project) + write_json( + project / "slide_plan.json", + { + "output_mode": "svglide-svg", + "slides": [ + { + "page": 1, + "chart_type": "bar_chart", + "chart_decision": { + "status": "required", + "chart_type": "bar_chart", + "reason": "compare category values", + "data_ref": "brief", + "anchor_role": "chart", + }, + } + ], + }, + ) + args = self.args(project) + self.write_minimal_preflight_receipt(project, data, args) + + body = runner.run_chart_verify(project, data, args) + + self.assertEqual("failed", body["status"]) + self.assertEqual(1, body["summary"]["error_count"]) + self.assertEqual("chart_geometry_not_found", body["issues"][0]["code"]) + def test_quality_gate_allows_authoring_component_waiver_for_dry_run(self) -> None: project = self.make_project() data = runner.manifest(project) diff --git a/skills/lark-slides/scripts/svglide_renderer_registry.py b/skills/lark-slides/scripts/svglide_renderer_registry.py new file mode 100644 index 000000000..36c69b8fb --- /dev/null +++ b/skills/lark-slides/scripts/svglide_renderer_registry.py @@ -0,0 +1,170 @@ +#!/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 pathlib import Path +from typing import Any + + +REGISTRY_SCHEMA_VERSION = "svglide-renderer-registry/v1" +ACTIVE_STATUSES = {"active", "candidate", "blocked", "deprecated"} + + +def script_path() -> Path: + return Path(__file__).resolve() + + +def references_dir() -> Path: + return script_path().parents[1] / "references" + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def registry_path(ref_dir: Path | None = None) -> Path: + return (ref_dir or references_dir()) / "svglide-renderer-registry.json" + + +def load_registry(ref_dir: Path | None = None) -> dict[str, Any]: + data = read_json(registry_path(ref_dir)) + if not isinstance(data, dict): + raise ValueError("renderer registry must contain a JSON object") + return data + + +def load_catalog_ids(ref_dir: Path | None = None) -> dict[str, set[str]]: + root = ref_dir or references_dir() + seeds = read_json(root / "svg-seeds.json") + recipes = read_json(root / "svg-recipes.json") + if not isinstance(seeds, dict) or not isinstance(recipes, dict): + raise ValueError("seed and recipe catalogs must contain JSON objects") + seed_ids = set((seeds.get("seeds") or {}).keys()) if isinstance(seeds.get("seeds"), dict) else set() + recipe_ids = set((recipes.get("recipes") or {}).keys()) if isinstance(recipes.get("recipes"), dict) else set() + chart_type_ids = ( + set((recipes.get("chart_type_contracts") or {}).keys()) + if isinstance(recipes.get("chart_type_contracts"), dict) + else set() + ) + return {"seeds": seed_ids, "recipes": recipe_ids, "chart_types": chart_type_ids} + + +def text(value: Any) -> str: + return str(value).strip() if value is not None else "" + + +def list_text(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [text(item) for item in value if text(item)] + + +def validate_registry(data: dict[str, Any], catalog_ids: dict[str, set[str]]) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + if data.get("schema_version") != REGISTRY_SCHEMA_VERSION: + issues.append({"level": "error", "code": "invalid_schema_version"}) + renderers = data.get("renderers") + if not isinstance(renderers, list) or not renderers: + issues.append({"level": "error", "code": "missing_renderers"}) + renderers = [] + + seen: set[str] = set() + active_count = 0 + candidate_count = 0 + active_seed_ids: set[str] = set() + active_recipe_ids: set[str] = set() + active_page_kinds: set[str] = set() + for index, item in enumerate(renderers, 1): + if not isinstance(item, dict): + issues.append({"level": "error", "code": "renderer_not_object", "index": index}) + continue + renderer_id = text(item.get("id")) + status = text(item.get("status")) or "candidate" + seed_id = text(item.get("layout_seed_id")) + recipe_id = text(item.get("visual_recipe_id")) + page_kind = text(item.get("page_kind")) + if not renderer_id: + issues.append({"level": "error", "code": "missing_renderer_id", "index": index}) + continue + if renderer_id in seen: + issues.append({"level": "error", "code": "duplicate_renderer_id", "renderer_id": renderer_id}) + seen.add(renderer_id) + if status not in ACTIVE_STATUSES: + issues.append({"level": "error", "code": "invalid_status", "renderer_id": renderer_id, "status": status}) + if status == "active": + active_count += 1 + active_seed_ids.add(seed_id) + active_recipe_ids.add(recipe_id) + active_page_kinds.add(page_kind) + required = { + "page_kind": page_kind, + "runtime_renderer_family": text(item.get("runtime_renderer_family")), + "layout_seed_id": seed_id, + "visual_recipe_id": recipe_id, + } + for field, value in required.items(): + if not value: + issues.append({"level": "error", "code": f"active_renderer_missing_{field}", "renderer_id": renderer_id}) + if seed_id and seed_id not in catalog_ids["seeds"]: + issues.append({"level": "error", "code": "unknown_layout_seed", "renderer_id": renderer_id, "layout_seed_id": seed_id}) + if recipe_id and recipe_id not in catalog_ids["recipes"]: + issues.append({"level": "error", "code": "unknown_visual_recipe", "renderer_id": renderer_id, "visual_recipe_id": recipe_id}) + for chart_type in list_text(item.get("chart_types")): + if chart_type not in catalog_ids["chart_types"]: + issues.append({"level": "error", "code": "unknown_chart_type", "renderer_id": renderer_id, "chart_type": chart_type}) + if not list_text(item.get("style_reskin_hooks")): + issues.append({"level": "warning", "code": "missing_style_reskin_hooks", "renderer_id": renderer_id}) + if not list_text(item.get("required_primitives")): + issues.append({"level": "warning", "code": "missing_required_primitives", "renderer_id": renderer_id}) + elif status == "candidate": + candidate_count += 1 + + return { + "schema_version": REGISTRY_SCHEMA_VERSION, + "status": "passed" if not any(issue["level"] == "error" for issue in issues) else "failed", + "summary": { + "renderer_count": len(renderers), + "active_count": active_count, + "candidate_count": candidate_count, + "active_seed_count": len({item for item in active_seed_ids if item}), + "active_recipe_count": len({item for item in active_recipe_ids if item}), + "active_page_kind_count": len({item for item in active_page_kinds if item}), + "error_count": sum(1 for issue in issues if issue["level"] == "error"), + "warning_count": sum(1 for issue in issues if issue["level"] == "warning"), + }, + "issues": issues, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate the SVGlide renderer registry.") + parser.add_argument("--references-dir", default="", help="Override references directory") + parser.add_argument("--json", action="store_true", help="Emit JSON report") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + ref_dir = Path(args.references_dir).expanduser() if args.references_dir else references_dir() + report = validate_registry(load_registry(ref_dir), load_catalog_ids(ref_dir)) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + summary = report["summary"] + print( + "renderer registry: " + f"{report['status']} " + f"({summary['active_count']} active, {summary['candidate_count']} candidate, " + f"{summary['error_count']} errors, {summary['warning_count']} warnings)" + ) + return 0 if report["status"] == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/lark-slides/scripts/svglide_renderer_registry_test.py b/skills/lark-slides/scripts/svglide_renderer_registry_test.py new file mode 100644 index 000000000..71350f957 --- /dev/null +++ b/skills/lark-slides/scripts/svglide_renderer_registry_test.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import contextlib +import io +import json +import sys +import unittest +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import svglide_renderer_registry as registry + + +class SVGlideRendererRegistryTest(unittest.TestCase): + def test_registry_validates_active_renderers_against_catalogs(self) -> None: + report = registry.validate_registry(registry.load_registry(), registry.load_catalog_ids()) + + self.assertEqual("passed", report["status"]) + self.assertGreaterEqual(report["summary"]["active_count"], 10) + self.assertGreaterEqual(report["summary"]["active_page_kind_count"], 10) + self.assertEqual(0, report["summary"]["error_count"]) + + def test_registry_has_no_external_reference_project_words(self) -> None: + encoded = json.dumps(registry.load_registry(), ensure_ascii=False).lower() + banned_tokens = [ + "ppt" + "-master", + "ppt" + "_master", + "ppt" + " master", + "hugo" + "he3", + "ppt" + "169", + ] + + for token in banned_tokens: + with self.subTest(token=token): + self.assertNotIn(token, encoded) + + def test_cli_json_report(self) -> None: + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = registry.main(["--json"]) + + self.assertEqual(0, exit_code) + report = json.loads(stdout.getvalue()) + self.assertEqual("svglide-renderer-registry/v1", report["schema_version"]) + self.assertEqual("passed", report["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/lark-slides/scripts/svglide_strategist.py b/skills/lark-slides/scripts/svglide_strategist.py index 0cf1ddd6e..a9523fae2 100644 --- a/skills/lark-slides/scripts/svglide_strategist.py +++ b/skills/lark-slides/scripts/svglide_strategist.py @@ -286,12 +286,20 @@ def load_catalogs(ref_dir: Path | None = None) -> dict[str, Any]: seed_data = read_json(root / "svg-seeds.json") recipe_data = read_json(root / "svg-recipes.json") pattern_data = read_json(root / "svglide-design-pattern-map.json") + renderer_path = root / "svglide-renderer-registry.json" + renderer_data = read_json(renderer_path) if renderer_path.exists() else {"renderers": []} + renderers = { + item["id"]: item + for item in renderer_data.get("renderers", []) + if isinstance(item, dict) and isinstance(item.get("id"), str) and item.get("id") + } return { "style_presets": {item["style_id"]: item for item in style_data.get("presets", []) if isinstance(item, dict) and item.get("style_id")}, "seeds": seed_data.get("seeds", {}), "recipes": recipe_data.get("recipes", {}), "chart_type_contracts": recipe_data.get("chart_type_contracts", {}), "pattern_ids": {item.get("id") for item in pattern_data.get("resources", []) if isinstance(item, dict) and item.get("id")}, + "renderers": renderers, } @@ -659,6 +667,7 @@ def complete_slide(slide: dict[str, Any], *, brief: str, fallback_description: s text = slide_text(completed, fallback_description) or brief profile = classify_profile(text, index=index, total=total) seed_id, seed, profile_data = seed_for_slide(completed, profile, catalogs) + renderer_contract = catalogs.get("renderers", {}).get(profile_data["renderer_id"], {}) recipe = compact_text(completed.get("visual_recipe")) or compact_text(seed.get("visual_recipe")) recipe_contract = catalogs["recipes"].get(recipe, {}) required_primitives = list_union(recipe_contract.get("required_primitives"), seed.get("required_primitives"), completed.get("required_primitives")) @@ -667,6 +676,10 @@ def complete_slide(slide: dict[str, Any], *, brief: str, fallback_description: s setdefault_clone(completed, "page", index + 1) setdefault_clone(completed, "key_message", first_present(completed, ("key_message", "one_idea", "title", "headline", "description")) or text) setdefault_clone(completed, "renderer_id", profile_data["renderer_id"]) + if isinstance(renderer_contract, dict) and renderer_contract: + setdefault_clone(completed, "renderer_registry_status", renderer_contract.get("status")) + setdefault_clone(completed, "runtime_renderer_family", renderer_contract.get("runtime_renderer_family")) + setdefault_clone(completed, "style_reskin_hooks", renderer_contract.get("style_reskin_hooks", [])) setdefault_clone(completed, "page_rhythm", profile_data["page_rhythm"]) setdefault_clone(completed, "page_type", profile_data["page_type"]) setdefault_clone(completed, "chart_type", profile_data["chart_type"])