mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
feat(slides): add svglide pipeline receipts
This commit is contained in:
@@ -162,7 +162,7 @@ seed skeleton 不是灵感参考,而是初始版式骨架。生成器必须保
|
||||
|
||||
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/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[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
|
||||
|
||||
@@ -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",
|
||||
|
||||
28
skills/lark-slides/references/svglide-absorption-matrix.md
Normal file
28
skills/lark-slides/references/svglide-absorption-matrix.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
185
skills/lark-slides/references/svglide-renderer-registry.json
Normal file
185
skills/lark-slides/references/svglide-renderer-registry.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
170
skills/lark-slides/scripts/svglide_renderer_registry.py
Normal file
170
skills/lark-slides/scripts/svglide_renderer_registry.py
Normal file
@@ -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())
|
||||
54
skills/lark-slides/scripts/svglide_renderer_registry_test.py
Normal file
54
skills/lark-slides/scripts/svglide_renderer_registry_test.py
Normal file
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user