from __future__ import annotations import csv import json import re import shutil import urllib.request import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] FLOWZAP_APP_ROOT = REPO_ROOT.parent / 'FlowZap-App-v5' TEMPLATES_INDEX_PATH = FLOWZAP_APP_ROOT / 'src' / 'templateLibrary' / 'templatesIndex.json' TEMPLATE_FILES_DIR = FLOWZAP_APP_ROOT / 'public' / 'template-files' SITEMAP_URL = 'https://flowzap.xyz/sitemap-templates.xml' USE_CASES = [ { 'id': 'handoff', 'labels': {'en': 'Handoff', 'fr': 'Transfert', 'zh': '交接'}, 'keywords': ['handoff', 'handover', 'transfer', 'escalation', 'routing', 'handoff degradation'], }, { 'id': 'approval', 'labels': {'en': 'Approval', 'fr': 'Approbation', 'zh': '审批'}, 'keywords': ['approval', 'approve', 'sign-off', 'review', 'authorize', 'authorization'], }, { 'id': 'incident', 'labels': {'en': 'Incident', 'fr': 'Incident', 'zh': '事故'}, 'keywords': ['incident', 'outage', 'alert', 'on-call', 'monitoring', 'response'], }, { 'id': 'onboarding', 'labels': {'en': 'Onboarding', 'fr': 'Intégration', 'zh': '入职'}, 'keywords': ['onboarding', 'onboard', 'welcome', 'setup', 'activation', 'registration'], }, { 'id': 'offboarding', 'labels': {'en': 'Offboarding', 'fr': 'Départ', 'zh': '离职'}, 'keywords': ['offboarding', 'termination', 'account deletion', 'deprovision', 'deactivate'], }, { 'id': 'billing', 'labels': {'en': 'Billing & Payments', 'fr': 'Facturation & paiements', 'zh': '计费与支付'}, 'keywords': ['billing', 'invoice', 'payment', 'subscription', 'dunning', 'refund', 'dispute', 'chargeback'], }, { 'id': 'security', 'labels': {'en': 'Security & Access', 'fr': 'Sécurité & accès', 'zh': '安全与访问'}, 'keywords': ['security', 'auth', 'authentication', 'oauth', '2fa', 'two-factor', 'mfa', 'certificate', 'rotation', 'secret', 'vulnerability', 'patch'], }, { 'id': 'compliance', 'labels': {'en': 'Compliance', 'fr': 'Conformité', 'zh': '合规'}, 'keywords': ['gdpr', 'audit', 'compliance', 'consent', 'policy', 'risk', 'controls'], }, { 'id': 'delivery', 'labels': {'en': 'Delivery & DevOps', 'fr': 'Livraison & DevOps', 'zh': '交付与 DevOps'}, 'keywords': ['ci/cd', 'ci-cd', 'pipeline', 'deploy', 'deployment', 'rollback', 'migration', 'backup', 'restore', 'provisioning', 'infrastructure', 'load testing'], }, { 'id': 'support', 'labels': {'en': 'Support', 'fr': 'Support', 'zh': '支持'}, 'keywords': ['support', 'ticket', 'knowledge base', 'knowledge-base'], }, { 'id': 'agentic', 'labels': {'en': 'Agentic Teams', 'fr': 'Équipes Agentiques', 'zh': '智能体团队'}, 'keywords': ['agentic', 'supervisor-worker', 'swarm', 'hierarchical', 'team-of-rivals', 'multi-agent', 'ai-team', 'orchestrator', 'planner', 'critic'], }, { 'id': 'ai-orchestration', 'labels': {'en': 'AI Orchestration', 'fr': 'Orchestration IA', 'zh': 'AI编排'}, 'keywords': ['ai-native', 'orchestration', 'single-agent', 'sequential-pipeline', 'parallel-fan-out', 'map-reduce', 'event-driven-mesh', 'competitive', 'generator-critic', 'coordinator'], }, { 'id': 'mcp', 'labels': {'en': 'MCP Architecture', 'fr': 'Architecture MCP', 'zh': 'MCP架构'}, 'keywords': ['mcp', 'direct-connect', 'gateway', 'proxy', 'tool-router', 'agent-mesh', 'circuit-breaker', 'context-proxy', 'stdio', 'production-grade'], }, { 'id': 'context-management', 'labels': {'en': 'Context Management', 'fr': 'Gestion de Contexte', 'zh': '上下文管理'}, 'keywords': ['context-management', 'session-memory', 'rolling-summary', 'profile-memory', 'semantic-memory', 'episodic-memory', 'hybrid-retrieval', 'shared-memory', 'vector-db', 'memory'], }, ] LEGACY_TOP_LEVEL_DIRS = [ 'business-operations', 'devops', 'edge-cases', 'finance', 'hr', 'logistics', 'patterns', 'syntax-primitives', 'handoff', 'approval', 'incident', 'onboarding', 'offboarding', 'billing', 'security', 'compliance', 'delivery', 'support', 'agentic', 'ai-orchestration', 'mcp', 'context-management', 'uncategorized', 'indexes', ] def normalize_text(value: str) -> str: return re.sub(r'\s+', ' ', value.lower().replace('_', ' ').replace('-', ' ')).strip() def slugify(value: str) -> str: return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-') or 'unknown' def load_canonical_slugs() -> list[str]: with urllib.request.urlopen(SITEMAP_URL, timeout=30) as response: xml_text = response.read().decode('utf-8') root = ET.fromstring(xml_text) ns = {'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9'} slugs: list[str] = [] seen: set[str] = set() for loc_node in root.findall('sm:url/sm:loc', ns): loc = (loc_node.text or '').strip() if '/fr/templates/' in loc or '/zh/templates/' in loc: continue marker = '/templates/' if marker not in loc: continue slug = loc.split(marker, 1)[1].strip('/').removesuffix('.html') if slug and slug not in seen: slugs.append(slug) seen.add(slug) return slugs def load_app_index() -> dict[str, dict]: data = json.loads(TEMPLATES_INDEX_PATH.read_text(encoding='utf-8')) return {entry['slug']: entry for entry in data['templates']} def detect_kind(entry: dict) -> str: tags = entry.get('tags') or [] if entry.get('domain') == 'Architecture' or 'architecture-diagram' in tags: return 'architecture' return 'workflow' def compute_use_cases(entry: dict) -> list[str]: haystack = normalize_text( ' '.join( str(part) for part in [entry.get('slug', ''), entry.get('domain', ''), entry.get('sourcePath', ''), *(entry.get('tags') or [])] if part ) ) matches: list[str] = [] for use_case in USE_CASES: if any(normalize_text(keyword) in haystack for keyword in use_case['keywords']): matches.append(use_case['id']) return matches def make_readme(entry: dict, primary_use_case: str, secondary_use_cases: list[str], relative_dir: str) -> str: title = entry['title'] description = entry.get('description') or '' source_domain = entry.get('domain') or 'unknown' tags = entry.get('tags') or [] lines = [ f'# {title}', '', description, '', f'**Template ID:** `{entry["slug"]}` ', f'**Kind:** {detect_kind(entry)} ', f'**Source domain:** {source_domain} ', f'**Primary use case:** {primary_use_case} ', f'**Secondary use cases:** {", ".join(secondary_use_cases) if secondary_use_cases else "None"} ', f'**Complexity:** {entry.get("complexity", "n/a")}/5 ', f'**Nodes:** {entry.get("numNodes", "n/a")} ', f'**Lanes:** {entry.get("numLanes", "n/a")} ', f'**Tags:** [{", ".join(tags)}] ', f'**Source path:** `{entry.get("sourcePath", "")}` ', f'**Repo path:** `{relative_dir}`', '', '## Files', '- `workflow.fz` — FlowZap Code source', '- `metadata.json` — Structured metadata', ] return '\n'.join(lines).strip() + '\n' def clean_repo_root() -> None: for name in LEGACY_TOP_LEVEL_DIRS: path = REPO_ROOT / name if path.is_dir(): shutil.rmtree(path) for name in ('metadata.csv', 'templates.index.json', 'use-cases.index.json', 'README.md', 'CONTRIBUTING.md'): path = REPO_ROOT / name if path.exists(): path.unlink() def write_root_docs(total_templates: int, counts: Counter) -> None: readme_lines = [ '# sequence-workflows', '', f'A synchronized FlowZap template corpus with **{total_templates} canonical templates** sourced from `https://flowzap.xyz/sitemap-templates.xml` and organized by **primary Use Case**.', '', '## Organization Model', '', '- Top-level folders are primary Use Cases from the FlowZap Templates dropdown.', '- Second-level folders preserve the original source domain from the FlowZap app index.', '- Each template keeps all matched Use Cases in `metadata.json` and the generated JSON/CSV indexes.', '- Templates that do not match any current dropdown Use Case are placed under `uncategorized/`.', '', '## Top-Level Folders', '', ] for use_case in USE_CASES: readme_lines.append(f'- `{use_case["id"]}/` — {use_case["labels"]["en"]} ({counts.get(use_case["id"], 0)})') readme_lines.append(f'- `uncategorized/` — Unmatched canonical templates ({counts.get("uncategorized", 0)})') readme_lines.extend([ '', '## Generated Indexes', '', '- `templates.index.json` — Full machine-readable template index', '- `use-cases.index.json` — Use Case summary with counts and template paths', '- `metadata.csv` — Flat CSV export of core template metadata', '', '## Sync Source', '', '- Canonical URL source: `https://flowzap.xyz/sitemap-templates.xml`', '- Template metadata source: `FlowZap-App-v5/src/templateLibrary/templatesIndex.json`', '- Template file source: `FlowZap-App-v5/public/template-files/*.fz`', ]) (REPO_ROOT / 'README.md').write_text('\n'.join(readme_lines).strip() + '\n', encoding='utf-8') contributing_lines = [ '# Contributing', '', 'This repository is synchronized from the FlowZap application template library.', '', '## Structure', '', '- `primary-use-case/source-domain/template-slug/`', '- `workflow.fz` contains the FlowZap Code source', '- `metadata.json` stores machine-readable metadata including primary and secondary Use Cases', '- `templates.index.json`, `use-cases.index.json`, and `metadata.csv` are generated artifacts', '', '## Sync Expectations', '', '- Keep the sitemap-driven template set aligned with `https://flowzap.xyz/sitemap-templates.xml`.', '- Preserve one canonical template copy per slug.', '- Use `primary_use_case` for folder placement and `use_cases` for the full taxonomy membership.', '- Preserve `source_domain` from the app index for traceability.', ] (REPO_ROOT / 'CONTRIBUTING.md').write_text('\n'.join(contributing_lines).strip() + '\n', encoding='utf-8') def main() -> None: if not FLOWZAP_APP_ROOT.exists(): raise SystemExit(f'FlowZap app not found at {FLOWZAP_APP_ROOT}') canonical_slugs = load_canonical_slugs() app_index = load_app_index() clean_repo_root() templates_index: list[dict] = [] use_case_index = {use_case['id']: {'label': use_case['labels']['en'], 'count': 0, 'templates': []} for use_case in USE_CASES} use_case_index['uncategorized'] = {'label': 'Uncategorized', 'count': 0, 'templates': []} counts: Counter = Counter() skipped_slugs: list[str] = [] for slug in canonical_slugs: entry = app_index.get(slug) if entry is None: skipped_slugs.append(slug) continue template_file = TEMPLATE_FILES_DIR / f'{slug}.fz' if not template_file.exists(): raise SystemExit(f'Missing template file: {template_file}') source_domain = entry.get('domain') or 'unknown' source_domain_slug = slugify(source_domain) use_cases = compute_use_cases(entry) primary_use_case = use_cases[0] if use_cases else 'uncategorized' secondary_use_cases = use_cases[1:] kind = detect_kind(entry) workflow_id = slug[:-9] if slug.endswith('-workflow') else slug relative_dir = f'{primary_use_case}/{source_domain_slug}/{slug}' target_dir = REPO_ROOT / relative_dir target_dir.mkdir(parents=True, exist_ok=True) shutil.copyfile(template_file, target_dir / 'workflow.fz') metadata = { 'template_id': slug, 'workflow_id': workflow_id, 'title': entry['title'], 'kind': kind, 'source_domain': source_domain, 'primary_use_case': primary_use_case, 'secondary_use_cases': secondary_use_cases, 'use_cases': use_cases, 'complexity': entry.get('complexity'), 'num_nodes': entry.get('numNodes'), 'num_lanes': entry.get('numLanes'), 'tags': entry.get('tags') or [], 'description': entry.get('description') or '', 'source_path': entry.get('sourcePath') or '', 'repo_path': relative_dir, } (target_dir / 'metadata.json').write_text(json.dumps(metadata, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') (target_dir / 'README.md').write_text(make_readme(entry, primary_use_case, secondary_use_cases, relative_dir), encoding='utf-8') record = { 'slug': slug, 'title': entry['title'], 'description': entry.get('description') or '', 'kind': kind, 'sourceDomain': source_domain, 'primaryUseCase': primary_use_case, 'secondaryUseCases': secondary_use_cases, 'useCases': use_cases, 'complexity': entry.get('complexity'), 'numNodes': entry.get('numNodes'), 'numLanes': entry.get('numLanes'), 'tags': entry.get('tags') or [], 'sourcePath': entry.get('sourcePath') or '', 'repoPath': relative_dir, } templates_index.append(record) use_case_index[primary_use_case]['count'] += 1 use_case_index[primary_use_case]['templates'].append({'slug': slug, 'title': entry['title'], 'repoPath': relative_dir}) counts[primary_use_case] += 1 (REPO_ROOT / 'templates.index.json').write_text(json.dumps({'templates': templates_index}, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') (REPO_ROOT / 'use-cases.index.json').write_text(json.dumps({'useCases': use_case_index}, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') with (REPO_ROOT / 'metadata.csv').open('w', encoding='utf-8', newline='') as handle: writer = csv.DictWriter(handle, fieldnames=['template_id', 'workflow_id', 'title', 'kind', 'source_domain', 'primary_use_case', 'secondary_use_cases', 'use_cases', 'complexity', 'num_nodes', 'num_lanes', 'tags', 'description', 'source_path', 'repo_path']) writer.writeheader() for item in templates_index: writer.writerow({ 'template_id': item['slug'], 'workflow_id': item['slug'][:-9] if item['slug'].endswith('-workflow') else item['slug'], 'title': item['title'], 'kind': item['kind'], 'source_domain': item['sourceDomain'], 'primary_use_case': item['primaryUseCase'], 'secondary_use_cases': ';'.join(item['secondaryUseCases']), 'use_cases': ';'.join(item['useCases']), 'complexity': item['complexity'], 'num_nodes': item['numNodes'], 'num_lanes': item['numLanes'], 'tags': ';'.join(item['tags']), 'description': item['description'], 'source_path': item['sourcePath'], 'repo_path': item['repoPath'], }) write_root_docs(len(templates_index), counts) if skipped_slugs: print('Skipped sitemap entries without template metadata:', ', '.join(skipped_slugs)) print(f'Synchronized {len(templates_index)} templates into {REPO_ROOT}') if __name__ == '__main__': main()