"""Assemblage du bundle Chat OTOIA depuis (contrat RBAC + spec + portails + seo). Sortie déterministe : - custom_block : 1 fixture Frappe `Custom Block` par portail (le carrier natif du chat, inséré dans le `content` du Workspace du portail côté VPS) ; - chat_mount : 1 config runtime par portail (persona, langues, rôles autorisés, portée de connaissance, capabilities, endpoint=null) — ce que le runtime OTOIA consomme ; données pures, diffables ; - manifest : traçabilité (comptes, résumé par portail, marque, hand-off VPS). Anti-invention (#6), garanti ici + par les invariants du CLI : - portails = EXACTEMENT les portails métier (portails_spec / portails_business), la console technique `plateforme` étant exclue (comme les Workspaces) ; - `roles_allowed` / `knowledge_scope` viennent du CONTRAT (surface RBAC du portail), jamais de la spec — mêmes valeurs que les Has Role des Workspaces ; - `endpoint` reste null (a_confirmer · VPS) — jamais fabriqué ; - persona / capabilities / langues repris de sources sourcées (CLAUDE.md, seo_spec), jamais inventés ; aucun chiffre émis. Déterminisme : portails dans l'ordre de la spec ; rôles + DocTypes triés → sortie reproductible bit-à-bit (aucun horodatage, aucun aléatoire). """ from __future__ import annotations from typing import Any from . import deps, frappe, knowledge def _portail_labels(portails_spec: dict) -> dict[str, str]: return {p["key"]: p["label"] for p in portails_spec["portails"]} def build_bundle( contract: dict, spec: dict, portails_spec: dict, langs: list[str], default_lang: str, ) -> dict[str, Any]: """Transforme les entrées en bundle {custom_block, chat_mount, manifest}.""" labels = _portail_labels(portails_spec) keys = [p["key"] for p in portails_spec["portails"]] # ordre spec = déterminisme mount_cfg = spec["mount"] id_prefix = mount_cfg["html_id_prefix"] css_class = mount_cfg["container_class"] bn_prefix = mount_cfg["block_name_prefix"] persona = {"nom": spec["persona"]["nom"], "voix": spec["persona"]["voix"]} capabilities = [c["module"] for c in spec["capabilities"]] ui_label = dict(spec["ui_label"]) custom_blocks: list[dict] = [] mounts: list[dict] = [] manifest_portails: list[dict] = [] for key in keys: label = labels[key] roles = knowledge.roles_for(contract, key) scope = knowledge.knowledge_scope_for(contract, key) html_id = f"{id_prefix}{key}" block_name = f"{bn_prefix}{label}" html = frappe.mount_html(html_id, key, persona["nom"], css_class) custom_blocks.append(frappe.custom_block_fixture(block_name, html)) mounts.append({ "portail": key, "workspace": label, "custom_block": block_name, "html_id": html_id, "persona": persona, "langues": list(langs), "lang_defaut": default_lang, "ui_label": ui_label, "roles_allowed": roles, "knowledge_scope": scope, "capabilities": list(capabilities), "endpoint": None, }) manifest_portails.append({ "portail": key, "workspace": label, "custom_block": block_name, "nb_roles": len(roles), "nb_knowledge_doctypes": len(scope), }) knowledge_union = sorted({dt for m in mounts for dt in m["knowledge_scope"]}) manifest = { "deliverable": spec["deliverable"], "sprint": spec["sprint"], "roadmap_ref": spec["roadmap_ref"], "generated_from": spec["generated_from"], "carrier": { "doctype": frappe.CUSTOM_BLOCK_DOCTYPE, "workspace_content_block_type": frappe.WORKSPACE_CONTENT_BLOCK_TYPE, "note": "Chaque Custom Block est inséré dans le `content` (editor.js) du " "Workspace de son portail via un bloc `custom_block` référençant " "`block_name`. La clé exacte du payload editor.js est a_confirmer " "selon le patch de version v15 — étape VPS (agent Frontend).", }, "persona": persona, "langues": list(langs), "lang_defaut": default_lang, "capabilities": list(capabilities), "endpoint_statut": spec["endpoint"]["statut"], "counts": { "portails": len(mounts), "custom_blocks": len(custom_blocks), "mounts": len(mounts), "roles_couverts": sum(m["nb_roles"] for m in manifest_portails), "knowledge_doctypes_uniques": len(knowledge_union), }, "portails": manifest_portails, "brand": { "fond": {"valeur": deps.branding.COLOR_BG, "source": "CLAUDE.md #4"}, "accent": {"valeur": deps.branding.COLOR_ACCENT, "source": "CLAUDE.md #4"}, "polices": [ {"valeur": deps.branding.FONT_DISPLAY, "source": "CLAUDE.md #4"}, {"valeur": deps.branding.FONT_BODY, "source": "CLAUDE.md #4"}, ], }, "handoff_vps": [ "Créer/confirmer le module propriétaire OTO côté app puis importer les " "fixtures `Custom Block` (bench).", "Insérer dans le `content` de chaque Workspace (frontend/portails/out/" "workspace.json) un bloc `custom_block` référençant `block_name` (payload " "editor.js a_confirmer selon version).", "Renseigner l'endpoint OTOIA (desk/bim-cloud) dans la config runtime + " "charger le web-component OTOIA via le thème desk (agent Frontend · #8).", "Appliquer les tokens de marque dark+doré (#4) au montage via le thème.", ], } return {"custom_block": custom_blocks, "chat_mount": mounts, "manifest": manifest}