feat(slides): add svglide pipeline receipts

This commit is contained in:
songtianyi.theo
2026-06-17 21:33:04 +08:00
parent 9aa38a3597
commit 4335ffaafd
14 changed files with 1161 additions and 22 deletions

View File

@@ -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[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:

View File

@@ -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",

View 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.

View File

@@ -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

View 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"
}
]
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -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",

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View 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())

View 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()

View File

@@ -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"])