#!/usr/bin/env python3 """Expand a concise FreeRTOS C card spec into card_source.json. The series spec remains the authored source for repeated metadata. Program code, debug recipes and PlantUML diagrams stay card-local. The resulting card_source.json is deterministic and is consumed by render_card.py. """ from __future__ import annotations import argparse import hashlib import json import subprocess import uuid from pathlib import Path from typing import Any GITEA_BASE_URL = "https://zsl-gitea.mpabi.pl" GITEA_SOURCE_ORG = "edu-freertos-c" PUBLICATION_HOST_UUID = "dce7fb9d-7b2f-5d49-96a2-3a30d3070b84" PUBLICATION_DOMAIN = "mpabi.pl" def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def git_blob(path: Path) -> str: result = subprocess.run( ["git", "hash-object", str(path)], check=True, text=True, capture_output=True, ) return result.stdout.strip() def stable_uuid(name: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, name)) def load_json(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as stream: return json.load(stream) def strategy(value: dict[str, Any]) -> dict[str, Any]: result = { "id": value["id"], "kind": value["kind"], "action": "open" if value["kind"] == "code" else "replay", "expected_observations": value["expected_observations"], "assertions": value["assertions"], "evidence_fields": value["evidence_fields"], } for key in ("prerequisites", "layout", "commands", "code_ref"): if key in value: result[key] = value[key] return result def interactive_asset( card_id: str, task_label: str, section: dict[str, Any], asset: dict[str, Any], ) -> dict[str, Any]: phases = asset["phases"] step_tiles = { step["id"]: 1 for phase in phases for step in phase["steps"] } orientation = section.get("orientation", "portrait") page_width = 940 if orientation == "landscape" else 660 page_height = 560 if orientation == "landscape" else 760 kind = "uml-sequence" if asset.get("diagram_kind") == "sequence" else "uml-class" stem = asset["stem"] return { "path": f"assets/{stem}.png", "html_path": f"assets/{stem}.svg", "source_path": f"assets/{stem}.puml", "caption": asset["caption"], "label": asset["label"], "alt": asset["alt"], "kind": "diagram", "width": 1.0, "page_grid": { "columns": 1, "rows": 1, "page_width": page_width, "page_height": page_height, "overview": True, "step_tiles": step_tiles, }, "interactive": { "kind": kind, "storage_key": f"{card_id.lower()}-{stem}", "title": asset["title"], "task": {"id": "task01", "label": task_label}, "block": { "id": section["id"].lower(), "label": f"{section['id']} {section['label']}", "description": section["description"], }, "phases": phases, }, } def section_value( card_id: str, task_label: str, section: dict[str, Any], ) -> dict[str, Any]: result: dict[str, Any] = { "title": f"{section['id']} — {section['title']}", "order": section["order"], "content_kind": "prose", "asset_page_mode": "one-per-page", "page_orientation": section.get("orientation", "portrait"), "content_tex": section["content_tex"], "assets": [ interactive_asset(card_id, task_label, section, asset) for asset in section["assets"] ], } for key in ("spread_group", "spread_label", "spread_columns"): if key in section: result[key] = section[key] return result def tree_item( effect_ref: str, criterion_ref: str, display: str, tree_id: str, text: str, ) -> dict[str, Any]: return { "effect_ref": effect_ref, "display": display, "source": "LOCAL", "official": "RTOS", "local": display.rsplit(".", 1)[-1], "kind": "EK" if ".TECH." in tree_id else "EN", "tree_id": tree_id, "text": text, "kw": [{ "criterion_ref": criterion_ref, "display": display.replace("EN ", "KW ").replace("EK ", "KW "), "source": "LOCAL", "kind": "KW", "official": "RTOS", "local": display.rsplit(".", 1)[-1], "text": "Łączy kod, diagram i zweryfikowany dowód.", }], } def build(repo: Path, spec: dict[str, Any]) -> dict[str, Any]: card = spec["card"] number = card["number"] prefix = f"FC{number}" repo_name = repo.name card_id = f"mpabi-freertos-c-{number}-{card['slug']}" card_uuid = stable_uuid(f"https://stem.local/cards/{card_id}") task_uuid = stable_uuid(f"https://stem.local/cards/{card_id}/task01") task_label = f"Task01 · {spec['task']['short_label']}" revision_date = card.get("revision_date", "2026-07-19T00:00:00+02:00") source = repo / spec["artifact"]["source"] elf = repo / spec["artifact"]["elf"] image = repo / spec["artifact"]["image"] viewpoints = [] targets = {section["id"]: section["assets"][0]["label"] for section in spec["sections"]} for viewpoint in spec["viewpoints"]: item = { "id": viewpoint["id"], "label": viewpoint["label"], "subtitle": viewpoint.get("subtitle", ""), "status": viewpoint["status"], } if viewpoint["status"] == "enabled": item["target_label"] = targets[viewpoint["id"]] else: item["reason"] = viewpoint["reason"] viewpoints.append(item) criterion_ref = f"{prefix}.KW01" en_ref = f"{prefix}.EN01" ek_ref = f"{prefix}.EK01" requirement_ref = f"{prefix}.WE01" sections = [ section_value(prefix, task_label, section) for section in spec["sections"] ] sections.append({ "title": f"Task01 — {spec['task']['short_label']}", "order": 90, "content_kind": "tasks", "task_refs": ["task01"], }) return { "$schema": "../../../tools/card-layouts/schemas/card-source.schema.json", "schema": "esc-card-source.v1", "card": { "id": card_id, "series": "freertos-c", "series_title": "FreeRTOS C", "number": number, "count": "15", "slug": card["slug"], "title": card["title"], "topic": card["topic"], "project": "Freestanding C nad FreeRTOS", "subject": "Informatyka", "level": f"Rok 2 · L{number} · RV32I/Hazard3", "revision_date": revision_date, "status": card.get("status", "W przygotowaniu"), "version": card.get("version", "v00.01"), "uuid": card_uuid, "author": "M. Pabiszczak", "year": "2026", }, "generated": { "tex": "doc/generated/main.tex", "html": "web/index.html", "html_css": "web/style.css", "html_tree_inspector": False, "react_app": True, }, "render_dictionary": False, "viewpoints": viewpoints, "debug_strategies": { item["id"]: strategy(item) for item in spec["strategies"] }, "template": "templates/karta-klasyczna.json", "title_block": { "category": "KARTA PRACY · INFORMATYKA", "prepared_by": "M. Pabiszczak", "prepared_on": revision_date, "title": card["title"], "url": f"https://{PUBLICATION_HOST_UUID}.{PUBLICATION_DOMAIN}/{task_uuid}", "repository_url": f"{GITEA_BASE_URL}/{GITEA_SOURCE_ORG}/{repo_name}", "url_host_uuid": PUBLICATION_HOST_UUID, "url_domain": PUBLICATION_DOMAIN, "doc_uuid": task_uuid, "revision": card.get("version", "v00.01"), "issued_on": revision_date, "series": f"FREERTOS-C-{number}", "document_type": "karta pracy", "tool": "card-layouts", "show_qr": True, "show_repository_qr": True, "height_cm": 2.6, "repeat_on_every_page": True, "replace_front_matter": True, }, "front_page_scope": { "title": "Cel karty", "content_tex": spec["front"]["goal"], "scope_title": "Zakres karty", "scope_content_tex": spec["front"]["scope"], "scope_table": { "headers": ["Lekcja", "Task", "Najważniejsza idea", "Priorytet", "Status", "Version"], "rows": [{ "chapter": prefix, "task": "Task01", "idea_tex": card["topic"], "priority": "główny", "status": "ready", "version": card.get("version", "v00.01"), "key": True, }], }, }, "side_margin_tree_layout": { "columns": [ {"id": "zawodowe", "label": "TECH", "side": "left", "tree": "WE -> EK -> KW", "description": "Dowody runtime."}, {"id": "ogolne", "label": "OG", "side": "right", "tree": "WE -> EN -> KW", "description": "Model FreeRTOS C."}, ], }, "learning_effects": { en_ref: { "bloom_level": "Analiza", "label": spec["learning"]["model_label"], "text": spec["learning"]["model"], "assessment_criteria": [criterion_ref], }, ek_ref: { "bloom_level": "Zastosowanie", "label": spec["learning"]["replay_label"], "text": spec["learning"]["replay"], "assessment_criteria": [criterion_ref], }, }, "assessment_criteria": { criterion_ref: { "text": spec["learning"]["criterion"], "learning_effects": [en_ref, ek_ref], }, }, "educational_requirements": { requirement_ref: { "text": spec["learning"]["requirement"], "label": card["topic"], "learning_effects": [en_ref, ek_ref], "learning_tree": { "schema": "we-learning-tree.v1", "policy": "Każda aktywna kotwica wskazuje kod lub checkpoint.", "ogolne": [tree_item( en_ref, criterion_ref, f"EN LOCAL RTOS.{number}", f"{requirement_ref}.OG.LOCAL.RTOS.{number}", spec["learning"]["model"], )], "zawodowe": [tree_item( ek_ref, criterion_ref, f"EK LOCAL RTOS.{number}", f"{requirement_ref}.TECH.LOCAL.RTOS.{number}", spec["learning"]["replay"], )], }, }, }, "debug_checkpoints": { "schema": "stem-debug-checkpoints.v1", "semantics": "deterministic-replay", "artifact": { "source": spec["artifact"]["source"], "source_git_blob": git_blob(source), "elf": spec["artifact"]["elf"], "hazard3_elf_sha256": file_sha256(elf), "hazard3_image": spec["artifact"]["image"], "hazard3_image_sha256": file_sha256(image), }, "targets": { "hazard3-sim": { "adapter": "hazard3-reset-load-replay.v1", "baseline": "restart-backend", "clean_ram": True, }, }, "items": spec["checkpoints"], }, "sections": sections, "tasks": { "task01": { "title": spec["task"]["title"], "uuid": task_uuid, "prompt_tex": spec["task"]["prompt_tex"], "criterion": spec["learning"]["criterion"], "conclusion_tex": spec["task"]["conclusion_tex"], "flow": spec["task"]["flow"], "educational_requirement_refs": [requirement_ref], "learning_effect_refs": [en_ref, ek_ref], "assessment_criterion_ref": criterion_ref, }, }, "tasks_order": ["task01"], } def validate_references(card_source: dict[str, Any], repo: Path) -> None: checkpoints = set(card_source["debug_checkpoints"]["items"]) strategies = set(card_source["debug_strategies"]) for section in card_source["sections"]: for asset in section.get("assets", []): for phase in asset["interactive"]["phases"]: for step in phase["steps"]: if step["strategy_ref"] not in strategies: raise ValueError(f"missing strategy: {step['strategy_ref']}") code_ref = step.get("code_ref") if code_ref: file_name, line_text = code_ref.rsplit(":", 1) file_path = repo / file_name if not file_path.is_file(): raise ValueError(f"missing code_ref file: {code_ref}") line = int(line_text) line_count = sum(1 for _ in file_path.open(encoding="utf-8", errors="ignore")) if line < 1 or line > line_count: raise ValueError(f"invalid code_ref line: {code_ref}") if step.get("mode") == "RUN": snapshot = step.get("snapshot_ref") if snapshot not in checkpoints: raise ValueError(f"missing checkpoint: {snapshot}") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("repo", type=Path) parser.add_argument("--spec", default="json/card_spec.json") args = parser.parse_args() repo = args.repo.resolve() spec = load_json(repo / args.spec) card_source = build(repo, spec) validate_references(card_source, repo) output = repo / "json/card_source.json" output.parent.mkdir(parents=True, exist_ok=True) with output.open("w", encoding="utf-8") as stream: json.dump(card_source, stream, ensure_ascii=False, indent=2) stream.write("\n") print(output) if __name__ == "__main__": main()