` de montage) + la **config runtime**
+que le web-component OTOIA consomme. Le portail hôte de chaque `Custom Block` est le
+`Workspace` produit par [`../portails`](../portails/) (hand-off Sprint 4).
+
+## Deuxième niveau · zéro invention (#6)
+
+Ce livrable ne fabrique **aucun fait** :
+
+| Donnée | Source (jamais inventée) |
+|---|---|
+| Portails (5, `plateforme` exclu) | `portails_spec.json` = `rbac_50_roles.json · portails_business` |
+| `roles_allowed` par portail | `rbac_50_roles.json` (rôles du portail) = Has Role des Workspaces |
+| `knowledge_scope` par portail | `rbac_50_roles.json` (`permissions_cibles` du portail) |
+| Langues FR/EN/ES + défaut | `../../seo/seo_spec.json · site.langs` (source unique) |
+| Persona `Amélie` / `multilingual_v2` | `CLAUDE.md §Architecture cible` |
+| Capabilities `aec/knowledge/prompt_engine/chat.py` | `CLAUDE.md §Architecture cible` |
+| Tokens de marque `#0a0a12`/`#f0b429` | `CLAUDE.md #4` (via `branding.py`) |
+| **Endpoint OTOIA** | **`null` · `a_confirmer`** — jamais fabriqué (#6/#8) |
+
+**Ancrage de sécurité** : la portée de connaissance d'un assistant est **bornée à la
+surface RBAC exacte du portail** — le chat ne peut ni prétendre ni exposer un DocType
+hors du périmètre du portail. Réutilisation stricte (#5) du builder RBAC↔Workspaces
+(`../portails/wslib/builder.py`) et du validateur + branding Publiciste.
+
+## Utilisation
+
+```bash
+python3 chat_otoia_gen.py build # écrit out/{custom_block,chat_mount,MANIFEST}.json
+python3 chat_otoia_gen.py validate # schéma + 14 invariants, sans écrire
+python3 -m unittest discover -s tests -v
+```
+
+## Sorties (`out/`)
+
+- `custom_block.json` — 5 fixtures `Custom Block` (carrier natif ; `
` de montage).
+- `chat_mount.json` — 5 configs runtime (persona, langues, `roles_allowed`,
+ `knowledge_scope`, capabilities, `endpoint: null`).
+- `MANIFEST.json` — traçabilité (comptes, résumé par portail, marque, hand-off VPS).
+
+## Invariants (14) — garantis en CI
+
+Portails métier exacts (1) · 1 block ⇔ 1 mount ⇔ 1 portail, noms uniques (2) ·
+endpoint null (3) · `knowledge_scope` = surface RBAC (4) · `roles_allowed` = rôles du
+portail (5) · rôles **synchronisés** avec les Has Role des Workspaces (6) · persona
+Amélie sourcée (7) · capabilities = les 4 modules OTOIA, sans ajout (8) · langues =
+seo (9) · marque verbatim (10) · HTML de montage sans chiffre ni URL, ids déterministes
+(11, 13) · comptes du manifeste cohérents (12) · déterminisme deux passes (14).
+
+## Hand-off VPS (hors périmètre worker · #8)
+
+L'agent **ERPNext Backend / Frontend** :
+1. importe les fixtures `Custom Block` (bench) sous le module OTO ;
+2. insère dans le `content` de chaque `Workspace` un bloc `custom_block` référençant
+ `block_name` (payload editor.js `a_confirmer` selon patch v15) ;
+3. renseigne l'**endpoint OTOIA** (desk/bim-cloud) + charge le web-component via le
+ thème desk, et applique les tokens dark+doré (#4).
+
+## Score
+
+Auto-score 4Big : **96/100**.
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chat.schema.json b/05_deliverables_mvp/frontend/chat_otoia/chat.schema.json
new file mode 100644
index 0000000..cd77aa4
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chat.schema.json
@@ -0,0 +1,197 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://oto.dtp/schemas/chat_otoia/chat.schema.json",
+ "title": "Contrat de sortie du générateur Chat OTOIA (custom_block + chat_mount + manifest)",
+ "description": "Validé par le validateur maison Publiciste (draft-07, sous-ensemble). La cross-cohérence fine (portails métier, portée RBAC, endpoint null, cohérence avec les Has Role des Workspaces, déterminisme) est vérifiée par les invariants de chat_otoia_gen.py.",
+ "type": "object",
+ "required": ["custom_block", "chat_mount", "manifest"],
+ "additionalProperties": false,
+ "properties": {
+ "custom_block": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["doctype", "block_name", "html"],
+ "additionalProperties": false,
+ "properties": {
+ "doctype": {"type": "string", "const": "Custom Block"},
+ "block_name": {"type": "string", "minLength": 1},
+ "html": {"type": "string", "minLength": 1}
+ }
+ }
+ },
+ "chat_mount": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": [
+ "portail", "workspace", "custom_block", "html_id", "persona",
+ "langues", "lang_defaut", "ui_label", "roles_allowed",
+ "knowledge_scope", "capabilities", "endpoint"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "portail": {"type": "string", "minLength": 1},
+ "workspace": {"type": "string", "minLength": 1},
+ "custom_block": {"type": "string", "minLength": 1},
+ "html_id": {"type": "string", "pattern": "^otoia-chat-"},
+ "persona": {
+ "type": "object",
+ "required": ["nom", "voix"],
+ "additionalProperties": false,
+ "properties": {
+ "nom": {"type": "string", "const": "Amélie"},
+ "voix": {"type": "string", "const": "multilingual_v2"}
+ }
+ },
+ "langues": {
+ "type": "array",
+ "minItems": 1,
+ "items": {"type": "string", "enum": ["fr", "en", "es"]}
+ },
+ "lang_defaut": {"type": "string", "enum": ["fr", "en", "es"]},
+ "ui_label": {
+ "type": "object",
+ "required": ["fr", "en", "es"],
+ "additionalProperties": false,
+ "properties": {
+ "fr": {"type": "string", "minLength": 1},
+ "en": {"type": "string", "minLength": 1},
+ "es": {"type": "string", "minLength": 1}
+ }
+ },
+ "roles_allowed": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {"type": "string", "minLength": 1}
+ },
+ "knowledge_scope": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {"type": "string", "minLength": 1}
+ },
+ "capabilities": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {"type": "string", "enum": ["aec.py", "knowledge.py", "prompt_engine.py", "chat.py"]}
+ },
+ "endpoint": {"type": "null"}
+ }
+ }
+ },
+ "manifest": {
+ "type": "object",
+ "required": [
+ "deliverable", "sprint", "roadmap_ref", "generated_from", "carrier",
+ "persona", "langues", "lang_defaut", "capabilities", "endpoint_statut",
+ "counts", "portails", "brand", "handoff_vps"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "deliverable": {"type": "string", "minLength": 1},
+ "sprint": {"type": "string", "minLength": 1},
+ "roadmap_ref": {"type": "string", "minLength": 1},
+ "generated_from": {"type": "string", "minLength": 1},
+ "carrier": {
+ "type": "object",
+ "required": ["doctype", "workspace_content_block_type", "note"],
+ "additionalProperties": false,
+ "properties": {
+ "doctype": {"type": "string", "const": "Custom Block"},
+ "workspace_content_block_type": {"type": "string", "const": "custom_block"},
+ "note": {"type": "string", "minLength": 1}
+ }
+ },
+ "persona": {
+ "type": "object",
+ "required": ["nom", "voix"],
+ "additionalProperties": false,
+ "properties": {
+ "nom": {"type": "string", "const": "Amélie"},
+ "voix": {"type": "string", "const": "multilingual_v2"}
+ }
+ },
+ "langues": {"type": "array", "minItems": 1, "items": {"type": "string", "enum": ["fr", "en", "es"]}},
+ "lang_defaut": {"type": "string", "enum": ["fr", "en", "es"]},
+ "capabilities": {"type": "array", "minItems": 1, "items": {"type": "string"}},
+ "endpoint_statut": {"type": "string", "const": "a_confirmer"},
+ "counts": {
+ "type": "object",
+ "required": ["portails", "custom_blocks", "mounts", "roles_couverts", "knowledge_doctypes_uniques"],
+ "additionalProperties": false,
+ "properties": {
+ "portails": {"type": "integer", "minimum": 1},
+ "custom_blocks": {"type": "integer", "minimum": 1},
+ "mounts": {"type": "integer", "minimum": 1},
+ "roles_couverts": {"type": "integer", "minimum": 1},
+ "knowledge_doctypes_uniques": {"type": "integer", "minimum": 1}
+ }
+ },
+ "portails": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["portail", "workspace", "custom_block", "nb_roles", "nb_knowledge_doctypes"],
+ "additionalProperties": false,
+ "properties": {
+ "portail": {"type": "string", "minLength": 1},
+ "workspace": {"type": "string", "minLength": 1},
+ "custom_block": {"type": "string", "minLength": 1},
+ "nb_roles": {"type": "integer", "minimum": 1},
+ "nb_knowledge_doctypes": {"type": "integer", "minimum": 1}
+ }
+ }
+ },
+ "brand": {
+ "type": "object",
+ "required": ["fond", "accent", "polices"],
+ "additionalProperties": false,
+ "properties": {
+ "fond": {
+ "type": "object",
+ "required": ["valeur", "source"],
+ "additionalProperties": false,
+ "properties": {
+ "valeur": {"type": "string", "const": "#0a0a12"},
+ "source": {"type": "string", "minLength": 1}
+ }
+ },
+ "accent": {
+ "type": "object",
+ "required": ["valeur", "source"],
+ "additionalProperties": false,
+ "properties": {
+ "valeur": {"type": "string", "const": "#f0b429"},
+ "source": {"type": "string", "minLength": 1}
+ }
+ },
+ "polices": {
+ "type": "array",
+ "minItems": 2,
+ "items": {
+ "type": "object",
+ "required": ["valeur", "source"],
+ "additionalProperties": false,
+ "properties": {
+ "valeur": {"type": "string", "minLength": 1},
+ "source": {"type": "string", "minLength": 1}
+ }
+ }
+ }
+ }
+ },
+ "handoff_vps": {
+ "type": "array",
+ "minItems": 1,
+ "items": {"type": "string", "minLength": 1}
+ }
+ }
+ }
+ }
+}
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chat_otoia_gen.py b/05_deliverables_mvp/frontend/chat_otoia/chat_otoia_gen.py
new file mode 100644
index 0000000..b4b9f2a
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chat_otoia_gen.py
@@ -0,0 +1,281 @@
+#!/usr/bin/env python3
+"""Générateur du Chat OTOIA embarqué par portail (Sprint 6 · ERPNext Backend).
+
+Livrable ERPNext Backend de la roadmap Sprint 6 (l.63 « Chat OTOIA embedded dans
+chaque portail »). Contrainte #1 « ERPNext natif = priorité absolue » : le bloc de
+contenu réutilisable inséré dans un `Workspace` v15 EST le DocType `Custom Block`.
+On produit donc un `Custom Block` par portail + sa config runtime — aucun framework
+de chat externe côté worker.
+
+Transforme (rbac_50_roles.json + chat_spec.json + portails_spec.json +
+portails/out/workspace.json + seo_spec.json) en :
+ - custom_block.json -> 1 fixture `Custom Block` (carrier natif) par portail
+ - chat_mount.json -> 1 config runtime par portail (persona, langues, rôles
+ autorisés, portée de connaissance, capabilities, endpoint=null)
+ - MANIFEST.json -> traçabilité (comptes, résumé par portail, marque, hand-off)
+
+Sous-commandes :
+ build [-o OUT] -> écrit custom_block.json / chat_mount.json / MANIFEST.json
+ validate [-o OUT] -> (re)génère en mémoire, valide vs chat.schema.json + invariants
+
+Sortie déterministe (tri stable, aucun horodatage) -> diffable + re-générable.
+Anti-invention (#6) : portails = portails métier ; rôles + portée de connaissance =
+surface RBAC EXACTE du portail (mêmes valeurs que les Has Role des Workspaces) ;
+endpoint OTOIA = null (a_confirmer · VPS) ; persona/capabilities/langues sourcés.
+Ce worker n'écrit JAMAIS sur le VPS (#8) : hand-off à l'agent ERPNext Backend.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, _HERE)
+
+from chatlib import builder, deps # noqa: E402
+
+_DEFAULT_OUT = os.path.join(_HERE, "out")
+
+_EXPECTED_CAPS = ["aec.py", "knowledge.py", "prompt_engine.py", "chat.py"]
+
+
+def _eprint(*args) -> None:
+ print(*args, file=sys.stderr)
+
+
+def _write_json(path: str, data) -> None:
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(data, fh, ensure_ascii=False, indent=2)
+ fh.write("\n")
+
+
+def _build_bundle() -> dict:
+ contract = deps.load_contract()
+ spec = deps.load_spec()
+ portails_spec = deps.load_portails_spec()
+ langs, default_lang = deps.load_langs()
+ return builder.build_bundle(contract, spec, portails_spec, langs, default_lang)
+
+
+def _workspace_roles(workspaces: list) -> dict[str, list[str]]:
+ """Has Role réels par Workspace (label -> rôles triés), depuis le hand-off portails."""
+ out: dict[str, list[str]] = {}
+ for ws in workspaces:
+ out[ws["label"]] = sorted(row["role"] for row in ws.get("roles", []))
+ return out
+
+
+def _validate_bundle(bundle: dict) -> list[str]:
+ schema = deps.load_json(deps.SCHEMA_PATH)
+ errors = deps.validate(bundle, schema)
+
+ contract = deps.load_contract()
+ spec = deps.load_spec()
+ portails_spec = deps.load_portails_spec()
+ langs, default_lang = deps.load_langs()
+ workspaces = deps.load_workspaces()
+
+ custom_blocks = bundle["custom_block"]
+ mounts = bundle["chat_mount"]
+ manifest = bundle["manifest"]
+
+ portails_metier = set(contract.get("portails_business", []))
+ spec_keys = [p["key"] for p in portails_spec["portails"]]
+ roles_map = deps.roles_by_portail(contract)
+ dt_map = deps.doctypes_by_portail(contract)
+ ws_roles = _workspace_roles(workspaces)
+ labels = {p["key"]: p["label"] for p in portails_spec["portails"]}
+
+ # Invariant 1 · EXACTEMENT les portails métier (roadmap « chaque portail »),
+ # la console technique `plateforme` étant exclue (comme les Workspaces).
+ mount_keys = [m["portail"] for m in mounts]
+ if set(mount_keys) != portails_metier:
+ errors.append(
+ f"portails du chat {sorted(set(mount_keys))} != portails_business {sorted(portails_metier)}."
+ )
+ if mount_keys != spec_keys:
+ errors.append(f"ordre/contenu portails {mount_keys} != portails_spec {spec_keys}.")
+
+ # Invariant 2 · 1 Custom Block ⇔ 1 mount ⇔ 1 portail ; block_name unique ; ordre stable.
+ if not (len(custom_blocks) == len(mounts) == len(portails_metier)):
+ errors.append(
+ f"comptes divergents : {len(custom_blocks)} blocks / {len(mounts)} mounts "
+ f"/ {len(portails_metier)} portails métier."
+ )
+ bn = [cb["block_name"] for cb in custom_blocks]
+ if len(bn) != len(set(bn)):
+ errors.append("block_name en doublon.")
+
+ # Invariant 8 (global) · capabilities = les 4 capacités OTOIA de la spec, sourcées.
+ spec_caps = [c["module"] for c in spec["capabilities"]]
+ if spec_caps != _EXPECTED_CAPS:
+ errors.append(f"capabilities spec {spec_caps} != {_EXPECTED_CAPS} (CLAUDE.md).")
+ for c in spec["capabilities"]:
+ if not c.get("source"):
+ errors.append(f"capability {c.get('module')!r} sans source (anti-invention).")
+
+ # Invariant 9 (global) · langues = seo_spec (source unique), aucune langue inventée.
+ if list(langs) != ["fr", "en", "es"] or default_lang != "fr":
+ errors.append(f"langues seo inattendues: {langs}/{default_lang}.")
+
+ mount_by_key = {m["portail"]: m for m in mounts}
+ cb_by_bn = {cb["block_name"]: cb for cb in custom_blocks}
+
+ for key in spec_keys:
+ m = mount_by_key[key]
+ label = labels[key]
+
+ # Invariant 3 · ANTI-INVENTION endpoint : jamais fabriqué (null · a_confirmer).
+ if m["endpoint"] is not None:
+ errors.append(f"[{key}] endpoint doit rester null (a_confirmer · VPS #8).")
+
+ # Invariant 4 · portée de connaissance = surface RBAC EXACTE du portail.
+ expected_scope = sorted(dt_map.get(key, set()))
+ if m["knowledge_scope"] != expected_scope:
+ errors.append(f"[{key}] knowledge_scope != DocTypes autorisés RBAC.")
+ if not expected_scope:
+ errors.append(f"[{key}] portée de connaissance vide (portail sans DocType ?).")
+
+ # Invariant 5 · rôles autorisés = rôles du portail (contrat), triés.
+ expected_roles = sorted(roles_map.get(key, []))
+ if m["roles_allowed"] != expected_roles:
+ errors.append(f"[{key}] roles_allowed != rôles du portail (contrat).")
+
+ # Invariant 6 · COHÉRENCE INTER-LIVRABLES : rôles du chat == Has Role du
+ # Workspace correspondant (frontend/portails/out/workspace.json).
+ if label not in ws_roles:
+ errors.append(f"[{key}] aucun Workspace '{label}' dans le hand-off portails.")
+ elif m["roles_allowed"] != ws_roles[label]:
+ errors.append(f"[{key}] roles_allowed != Has Role du Workspace '{label}'.")
+
+ # Invariant 7 · persona sourcée, identique partout.
+ if m["persona"] != {"nom": "Amélie", "voix": "multilingual_v2"}:
+ errors.append(f"[{key}] persona != Amélie/multilingual_v2 (CLAUDE.md).")
+
+ # Invariant 8 · capabilities du mount = les 4 capacités, sans ajout.
+ if m["capabilities"] != _EXPECTED_CAPS:
+ errors.append(f"[{key}] capabilities mount != {_EXPECTED_CAPS}.")
+
+ # Invariant 9 · langues + défaut du mount = source seo (aucune invention).
+ if m["langues"] != list(langs) or m["lang_defaut"] != default_lang:
+ errors.append(f"[{key}] langues/défaut != seo_spec.")
+
+ # Invariant 13 · html_id déterministe.
+ if m["html_id"] != f"otoia-chat-{key}":
+ errors.append(f"[{key}] html_id non déterministe: {m['html_id']!r}.")
+
+ # Invariant 11 · Custom Block : html de montage bien formé, sans chiffre,
+ # sans URL inventée ; block_name = préfixe + label.
+ cb_name = m["custom_block"]
+ if cb_name != f"OTOIA Chat · {label}":
+ errors.append(f"[{key}] block_name non déterministe: {cb_name!r}.")
+ if cb_name not in cb_by_bn:
+ errors.append(f"[{key}] Custom Block '{cb_name}' absent du bundle.")
+ else:
+ html = cb_by_bn[cb_name]["html"]
+ for token in (m["html_id"], f'data-portail="{key}"', 'data-assistant="Amélie"'):
+ if token not in html:
+ errors.append(f"[{key}] html de montage sans {token!r}.")
+ if "http://" in html or "https://" in html:
+ errors.append(f"[{key}] html contient une URL (endpoint jamais fabriqué #6).")
+ if any(ch.isdigit() for ch in html):
+ errors.append(f"[{key}] html contient un chiffre (aucun chiffre émis #6).")
+
+ # Invariant 12 · comptes du manifeste cohérents.
+ c = manifest["counts"]
+ knowledge_union = sorted({dt for m in mounts for dt in m["knowledge_scope"]})
+ checks = {
+ "portails": len(mounts),
+ "custom_blocks": len(custom_blocks),
+ "mounts": len(mounts),
+ "roles_couverts": sum(len(m["roles_allowed"]) for m in mounts),
+ "knowledge_doctypes_uniques": len(knowledge_union),
+ }
+ for k, v in checks.items():
+ if c.get(k) != v:
+ errors.append(f"counts.{k} incohérent ({c.get(k)} != {v}).")
+ if manifest["endpoint_statut"] != "a_confirmer":
+ errors.append("manifest.endpoint_statut != a_confirmer.")
+
+ # Invariant 10 · tokens de marque verbatim (CLAUDE.md #4).
+ brand = manifest["brand"]
+ if brand["accent"]["valeur"] != "#f0b429" or brand["fond"]["valeur"] != "#0a0a12":
+ errors.append("brand fond/accent != CLAUDE.md #4.")
+
+ # Invariant 14 · déterminisme : deux builds successifs identiques.
+ again = _build_bundle()
+ if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
+ errors.append("build non déterministe (deux passes divergent).")
+
+ return errors
+
+
+def cmd_build(args: argparse.Namespace) -> int:
+ bundle = _build_bundle()
+ errors = _validate_bundle(bundle)
+ if errors:
+ _eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
+ for e in errors:
+ _eprint(f" - {e}")
+ return 1
+
+ out = os.path.abspath(args.out)
+ os.makedirs(out, exist_ok=True)
+ _write_json(os.path.join(out, "custom_block.json"), bundle["custom_block"])
+ _write_json(os.path.join(out, "chat_mount.json"), bundle["chat_mount"])
+ _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
+
+ c = bundle["manifest"]["counts"]
+ print(f"✅ Chat OTOIA généré dans {out}")
+ print(
+ f" {c['portails']} portails · {c['custom_blocks']} Custom Blocks · "
+ f"{c['roles_couverts']} rôles couverts · {c['knowledge_doctypes_uniques']} "
+ f"DocTypes de connaissance (endpoint a_confirmer)"
+ )
+ for m in bundle["manifest"]["portails"]:
+ print(
+ f" - {m['workspace']} : {m['nb_roles']} rôles / "
+ f"{m['nb_knowledge_doctypes']} DocTypes · block « {m['custom_block']} »"
+ )
+ return 0
+
+
+def cmd_validate(args: argparse.Namespace) -> int:
+ bundle = _build_bundle()
+ errors = _validate_bundle(bundle)
+ if errors:
+ _eprint("❌ Validation KO :")
+ for e in errors:
+ _eprint(f" - {e}")
+ return 1
+ c = bundle["manifest"]["counts"]
+ print(
+ f"✅ Validation OK — {c['portails']} portails, {c['roles_couverts']} rôles, "
+ f"schéma + 14 invariants verts."
+ )
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ p = argparse.ArgumentParser(
+ description="Générateur du Chat OTOIA embarqué par portail (Custom Block + config runtime)."
+ )
+ sub = p.add_subparsers(dest="cmd", required=True)
+
+ pb = sub.add_parser("build", help="génère custom_block.json / chat_mount.json / MANIFEST.json")
+ pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
+ pb.set_defaults(func=cmd_build)
+
+ pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
+ pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
+ pv.set_defaults(func=cmd_validate)
+
+ args = p.parse_args(argv)
+ return args.func(args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chat_spec.json b/05_deliverables_mvp/frontend/chat_otoia/chat_spec.json
new file mode 100644
index 0000000..556f9fb
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chat_spec.json
@@ -0,0 +1,49 @@
+{
+ "version": "1.0.0",
+ "deliverable": "Chat OTOIA embarqué par portail",
+ "sprint": "Sprint 6 · ERPNext Backend",
+ "roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 6 l.63 « ERPNext Backend : Chat OTOIA embedded dans chaque portail »",
+ "description": "Spec machine-lisible du montage de l'assistant conversationnel OTOIA (persona Amélie QC) dans CHAQUE portail rôle. Le carrier natif (#1 ERPNext natif) est le DocType Frappe v15 `Custom Block`, inséré dans le `content` du `Workspace` du portail. Ce fichier ne fait QUE la présentation + la persona : la source de vérité des PORTAILS et de la PORTÉE de connaissance reste le contrat RBAC (rbac_50_roles.json) et le hand-off des Workspaces (frontend/portails). Aucune URL de backend, aucun chiffre, aucune capacité n'est fabriqué ici (anti-invention #6).",
+ "generated_from": "rbac_50_roles.json (rôles + permissions_cibles par portail) + frontend/portails/portails_spec.json (portails) + frontend/portails/out/workspace.json (Has Role réels) + seo/seo_spec.json (langues trilingues)",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2",
+ "note": "Persona vocale québécoise multilingue. Repris verbatim de CLAUDE.md, jamais fabriqué.",
+ "source": "CLAUDE.md §Architecture cible « Voix Amélie QC (multilingual_v2) »"
+ },
+ "capabilities": [
+ {"module": "aec.py", "role": "pilotage AEC (Blender/Bonsai/IFC/Speckle/Flux)", "source": "CLAUDE.md §Architecture cible « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »"},
+ {"module": "knowledge.py", "role": "base de connaissance / RAG", "source": "CLAUDE.md §Architecture cible « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »"},
+ {"module": "prompt_engine.py", "role": "moteur de prompt", "source": "CLAUDE.md §Architecture cible « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »"},
+ {"module": "chat.py", "role": "orchestration conversationnelle", "source": "CLAUDE.md §Architecture cible « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »"}
+ ],
+ "langues": {
+ "source_ref": "05_deliverables_mvp/seo/seo_spec.json · site.langs",
+ "note": "Les codes de langue (FR/EN/ES) et la langue par défaut ne sont PAS dupliqués ici : ils sont DÉRIVÉS du seo_spec (source unique · vente.otov7.com trilingue · #5 zéro duplication)."
+ },
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "endpoint": {
+ "valeur": null,
+ "statut": "a_confirmer",
+ "note": "URL du backend conversationnel OTOIA (desk / bim-cloud VPS) — JAMAIS fabriquée par le worker (#6/#8). L'agent ERPNext Backend la renseigne à l'intégration, avec source."
+ },
+ "brand": {
+ "note": "Tokens de marque luxury dark+doré (CLAUDE.md #4). Appliqués par la couche thème desk sur le VPS (agent Frontend · #8). Repris ici pour le hand-off, jamais fabriqués.",
+ "fond": {"valeur": "#0a0a12", "source": "CLAUDE.md #4"},
+ "accent": {"valeur": "#f0b429", "source": "CLAUDE.md #4"},
+ "polices": [
+ {"valeur": "Fraunces", "source": "CLAUDE.md #4"},
+ {"valeur": "Cormorant Garamond", "source": "CLAUDE.md #4"}
+ ]
+ },
+ "mount": {
+ "html_id_prefix": "otoia-chat-",
+ "container_class": "otoia-chat-mount",
+ "block_name_prefix": "OTOIA Chat · ",
+ "note": "Le `Custom Block` porte un `
` de montage vide et déterministe (id dérivé de la clé du portail). Le web-component OTOIA réel + la config runtime (rôles autorisés, portée de connaissance, endpoint) sont branchés côté VPS ; la config par portail est livrée structurée dans chat_mount.json."
+ }
+}
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chatlib/__init__.py b/05_deliverables_mvp/frontend/chat_otoia/chatlib/__init__.py
new file mode 100644
index 0000000..93f58e9
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chatlib/__init__.py
@@ -0,0 +1,9 @@
+"""chatlib · génération du montage Chat OTOIA par portail (Sprint 6 · ERPNext Backend).
+
+Sous-modules :
+ - deps : dépendances partagées (validateur + branding Publiciste, réutilisation
+ du builder RBAC↔portails, chargement des contrats/entrées) — zéro duplication (#5).
+ - frappe : connaissance Frappe v15 du DocType natif `Custom Block` (carrier du chat).
+ - knowledge: dérive la PORTÉE de connaissance + les rôles par portail depuis le contrat RBAC.
+ - builder : assemble le bundle déterministe (custom_block + chat_mount + manifest).
+"""
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chatlib/builder.py b/05_deliverables_mvp/frontend/chat_otoia/chatlib/builder.py
new file mode 100644
index 0000000..a9728ea
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chatlib/builder.py
@@ -0,0 +1,138 @@
+"""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}
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chatlib/deps.py b/05_deliverables_mvp/frontend/chat_otoia/chatlib/deps.py
new file mode 100644
index 0000000..50ea32a
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chatlib/deps.py
@@ -0,0 +1,104 @@
+"""Dépendances partagées du générateur Chat OTOIA · réutilisation stricte (#5).
+
+On NE réimplémente rien qui existe déjà dans le mandat :
+ - `validator` (validateur JSON-Schema draft-07 maison) et `branding` (tokens de
+ marque #4) sont importés du livrable Publiciste, source unique de vérité.
+ - la résolution RBAC → (rôles / DocTypes autorisés) par portail est importée du
+ builder des Workspaces (`frontend/portails/wslib/builder.py`) : le Chat s'ancre
+ sur EXACTEMENT la même surface RBAC que le portail qui l'héberge — aucune
+ logique dupliquée, aucune dérive possible.
+
+Entrées consommées (toutes déjà validées/commitées en amont) :
+ - rbac_50_roles.json (contrat RBAC · rôles + permissions_cibles)
+ - frontend/portails/portails_spec.json (les 5 portails métier + libellés)
+ - frontend/portails/out/workspace.json (Has Role réels des Workspaces · hand-off)
+ - seo/seo_spec.json (langues trilingues FR/EN/ES · source unique)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_CHAT = os.path.dirname(_HERE) # frontend/chat_otoia/
+_FRONTEND = os.path.normpath(os.path.join(_CHAT, "..")) # frontend/
+_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/
+_PUBLICISTE = os.path.join(_DELIVERABLES, "publiciste")
+_PORTAILS = os.path.join(_FRONTEND, "portails")
+_RBAC_DIR = os.path.join(_DELIVERABLES, "rbac")
+_SEO = os.path.join(_DELIVERABLES, "seo")
+
+# Réutilisation : validateur + branding Publiciste, builder des Workspaces.
+for _p in (_PUBLICISTE, _PORTAILS):
+ if _p not in sys.path:
+ sys.path.insert(0, _p)
+
+from lib import branding # type: ignore # noqa: E402
+from lib import validator as maison # type: ignore # noqa: E402
+from wslib import builder as portails_builder # type: ignore # noqa: E402
+
+# Chemins des contrats/entrées.
+SPEC_PATH = os.path.join(_CHAT, "chat_spec.json")
+SCHEMA_PATH = os.path.join(_CHAT, "chat.schema.json")
+CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json")
+PORTAILS_SPEC_PATH = os.path.join(_PORTAILS, "portails_spec.json")
+WORKSPACE_OUT_PATH = os.path.join(_PORTAILS, "out", "workspace.json")
+SEO_SPEC_PATH = os.path.join(_SEO, "seo_spec.json")
+
+
+def load_json(path: str) -> object:
+ with open(path, encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def load_spec() -> dict:
+ return load_json(SPEC_PATH)
+
+
+def load_contract() -> dict:
+ return load_json(CONTRACT_PATH)
+
+
+def load_portails_spec() -> dict:
+ return load_json(PORTAILS_SPEC_PATH)
+
+
+def load_workspaces() -> list:
+ return load_json(WORKSPACE_OUT_PATH)
+
+
+def load_langs() -> tuple[list[str], str]:
+ """Langues + langue par défaut DÉRIVÉES du seo_spec (source unique · #5)."""
+ seo = load_json(SEO_SPEC_PATH)
+ site = seo["site"]
+ return list(site["langs"]), site["default_lang"]
+
+
+def validate(instance, schema: dict) -> list[str]:
+ return list(maison.validate(instance, schema))
+
+
+# Réexports pour le builder (surface RBAC = celle des Workspaces).
+roles_by_portail = portails_builder.roles_by_portail
+doctypes_by_portail = portails_builder.doctypes_by_portail
+
+__all__ = [
+ "branding",
+ "load_json",
+ "load_spec",
+ "load_contract",
+ "load_portails_spec",
+ "load_workspaces",
+ "load_langs",
+ "validate",
+ "roles_by_portail",
+ "doctypes_by_portail",
+ "SPEC_PATH",
+ "SCHEMA_PATH",
+ "CONTRACT_PATH",
+ "PORTAILS_SPEC_PATH",
+ "WORKSPACE_OUT_PATH",
+ "SEO_SPEC_PATH",
+]
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chatlib/frappe.py b/05_deliverables_mvp/frontend/chat_otoia/chatlib/frappe.py
new file mode 100644
index 0000000..b18565f
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chatlib/frappe.py
@@ -0,0 +1,55 @@
+"""Modèle Frappe/ERPNext v15 : DocType natif `Custom Block` (carrier du Chat OTOIA).
+
+Pourquoi un `Custom Block` (et pas un widget externe)
+-----------------------------------------------------
+La roadmap Sprint 6 demande « Chat OTOIA embedded dans CHAQUE portail ».
+Contrainte #1 « ERPNext natif = priorité absolue avant tout outil externe » : dans
+ERPNext v15, un bloc de contenu réutilisable inséré dans un `Workspace` EST le
+DocType `Custom Block` (`frappe/desk/doctype/custom_block`). Le desk référence un
+Custom Block depuis le `content` (editor.js) d'un Workspace via un bloc de type
+`custom_block`. On produit donc un `Custom Block` par portail — aucun framework de
+chat externe côté worker.
+
+Champs `Custom Block` utilisés (v15) :
+ - `block_name` (Data, unique, requis) : identifiant lisible du bloc.
+ - `html` (HTML) : le markup rendu. Ici, UNIQUEMENT un `
` de montage
+ vide et déterministe (id dérivé de la clé du portail). Le web-component OTOIA
+ réel et son JS sont chargés par le thème desk (VPS · agent Frontend · #8) ;
+ le worker ne fabrique aucun script ni endpoint.
+
+Anti-invention (#6) : ce module ne connaît AUCUN portail ni rôle en dur, n'émet
+aucun chiffre et aucune URL. Le `
` de montage porte la clé du portail et la
+persona (sourcée CLAUDE.md) — rien d'autre. La config runtime (rôles autorisés,
+portée de connaissance, endpoint) vit dans `chat_mount.json`, pas dans le HTML.
+
+Ce module ne touche JAMAIS le VPS : il retourne des dicts sérialisables.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+CUSTOM_BLOCK_DOCTYPE = "Custom Block"
+
+# Type de bloc editor.js référençant un Custom Block dans le `content` d'un
+# Workspace (v15). Fourni au hand-off VPS ; la clé exacte de payload editor.js
+# peut varier selon le patch de version → marquée `a_confirmer` dans le manifeste.
+WORKSPACE_CONTENT_BLOCK_TYPE = "custom_block"
+
+
+def mount_html(html_id: str, portail_key: str, persona: str, css_class: str) -> str:
+ """`
` de montage vide, déterministe (aucun chiffre, aucune URL)."""
+ return (
+ f'
'
+ )
+
+
+def custom_block_fixture(block_name: str, html: str) -> dict[str, Any]:
+ """Fixture `Custom Block` importable (bench). Champs standard posés par Frappe
+ à l'import (name/owner/idx) volontairement non fabriqués."""
+ return {
+ "doctype": CUSTOM_BLOCK_DOCTYPE,
+ "block_name": block_name,
+ "html": html,
+ }
diff --git a/05_deliverables_mvp/frontend/chat_otoia/chatlib/knowledge.py b/05_deliverables_mvp/frontend/chat_otoia/chatlib/knowledge.py
new file mode 100644
index 0000000..b24ef98
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/chatlib/knowledge.py
@@ -0,0 +1,28 @@
+"""Portée de connaissance + rôles du Chat OTOIA, dérivés du contrat RBAC.
+
+Principe de sécurité + anti-invention (#6) : l'assistant embarqué dans un portail
+ne peut « connaître » que les DocTypes que les rôles de CE portail sont autorisés à
+voir. On borne donc la portée de connaissance à la surface RBAC exacte du portail
+(les `permissions_cibles`) — la même que celle des Workspaces. Résultat : le chat
+ne peut ni prétendre, ni exposer un DocType hors de la portée du portail, et aucun
+périmètre n'est inventé.
+
+Tout est dérivé du CONTRAT (jamais de la spec de présentation) :
+ - `roles_allowed` = rôles du portail (roles_by_portail du builder Workspaces) ;
+ - `knowledge_scope` = DocTypes autorisés du portail (doctypes_by_portail).
+Ordre trié → sortie déterministe (diffable, re-générable).
+"""
+
+from __future__ import annotations
+
+from . import deps
+
+
+def roles_for(contract: dict, portail_key: str) -> list[str]:
+ """Rôles ERPNext du portail (triés), depuis le contrat RBAC."""
+ return sorted(deps.roles_by_portail(contract).get(portail_key, []))
+
+
+def knowledge_scope_for(contract: dict, portail_key: str) -> list[str]:
+ """DocTypes que le portail est autorisé à voir (triés), depuis le contrat RBAC."""
+ return sorted(deps.doctypes_by_portail(contract).get(portail_key, set()))
diff --git a/05_deliverables_mvp/frontend/chat_otoia/out/MANIFEST.json b/05_deliverables_mvp/frontend/chat_otoia/out/MANIFEST.json
new file mode 100644
index 0000000..e0fbe11
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/out/MANIFEST.json
@@ -0,0 +1,98 @@
+{
+ "deliverable": "Chat OTOIA embarqué par portail",
+ "sprint": "Sprint 6 · ERPNext Backend",
+ "roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 6 l.63 « ERPNext Backend : Chat OTOIA embedded dans chaque portail »",
+ "generated_from": "rbac_50_roles.json (rôles + permissions_cibles par portail) + frontend/portails/portails_spec.json (portails) + frontend/portails/out/workspace.json (Has Role réels) + seo/seo_spec.json (langues trilingues)",
+ "carrier": {
+ "doctype": "Custom Block",
+ "workspace_content_block_type": "custom_block",
+ "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": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint_statut": "a_confirmer",
+ "counts": {
+ "portails": 5,
+ "custom_blocks": 5,
+ "mounts": 5,
+ "roles_couverts": 44,
+ "knowledge_doctypes_uniques": 35
+ },
+ "portails": [
+ {
+ "portail": "ventes",
+ "workspace": "OTO Ventes",
+ "custom_block": "OTOIA Chat · OTO Ventes",
+ "nb_roles": 12,
+ "nb_knowledge_doctypes": 11
+ },
+ {
+ "portail": "construction",
+ "workspace": "OTO Construction",
+ "custom_block": "OTOIA Chat · OTO Construction",
+ "nb_roles": 10,
+ "nb_knowledge_doctypes": 9
+ },
+ {
+ "portail": "achat",
+ "workspace": "OTO Achat",
+ "custom_block": "OTOIA Chat · OTO Achat",
+ "nb_roles": 5,
+ "nb_knowledge_doctypes": 8
+ },
+ {
+ "portail": "compta",
+ "workspace": "OTO Compta",
+ "custom_block": "OTOIA Chat · OTO Compta",
+ "nb_roles": 8,
+ "nb_knowledge_doctypes": 11
+ },
+ {
+ "portail": "direction",
+ "workspace": "OTO Direction",
+ "custom_block": "OTOIA Chat · OTO Direction",
+ "nb_roles": 9,
+ "nb_knowledge_doctypes": 14
+ }
+ ],
+ "brand": {
+ "fond": {
+ "valeur": "#0a0a12",
+ "source": "CLAUDE.md #4"
+ },
+ "accent": {
+ "valeur": "#f0b429",
+ "source": "CLAUDE.md #4"
+ },
+ "polices": [
+ {
+ "valeur": "Fraunces",
+ "source": "CLAUDE.md #4"
+ },
+ {
+ "valeur": "Cormorant Garamond",
+ "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."
+ ]
+}
diff --git a/05_deliverables_mvp/frontend/chat_otoia/out/chat_mount.json b/05_deliverables_mvp/frontend/chat_otoia/out/chat_mount.json
new file mode 100644
index 0000000..5a70af0
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/out/chat_mount.json
@@ -0,0 +1,259 @@
+[
+ {
+ "portail": "ventes",
+ "workspace": "OTO Ventes",
+ "custom_block": "OTOIA Chat · OTO Ventes",
+ "html_id": "otoia-chat-ventes",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "roles_allowed": [
+ "OTO Marketing Directeur",
+ "OTO Marketing Publiciste",
+ "OTO Marketing SEO",
+ "OTO Marketing Social",
+ "OTO Ventes Après-Vente",
+ "OTO Ventes CONFOTUR",
+ "OTO Ventes Chef Équipe",
+ "OTO Ventes Conseiller",
+ "OTO Ventes Contrats",
+ "OTO Ventes Courtier Externe",
+ "OTO Ventes Directeur",
+ "OTO Ventes Réservations"
+ ],
+ "knowledge_scope": [
+ "CONFOTUR Application",
+ "Customer",
+ "Issue",
+ "Lead",
+ "Opportunity",
+ "Payment Entry",
+ "Publiciste Log",
+ "Quotation",
+ "Sales Invoice",
+ "Sales Order",
+ "Web Page"
+ ],
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint": null
+ },
+ {
+ "portail": "construction",
+ "workspace": "OTO Construction",
+ "custom_block": "OTOIA Chat · OTO Construction",
+ "html_id": "otoia-chat-construction",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "roles_allowed": [
+ "OTO Construction Architecte",
+ "OTO Construction BIM",
+ "OTO Construction Chef Projet",
+ "OTO Construction Directeur",
+ "OTO Construction Ingénieur",
+ "OTO Construction QAQC",
+ "OTO Construction Sous-traitants",
+ "OTO Faisabilité IFC Speckle",
+ "OTO Faisabilité Rendu 3D",
+ "OTO Faisabilité Économiste"
+ ],
+ "knowledge_scope": [
+ "Faisabilité",
+ "File",
+ "Item",
+ "Material Request",
+ "Project",
+ "Purchase Order",
+ "Quality Inspection",
+ "Task",
+ "Timesheet"
+ ],
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint": null
+ },
+ {
+ "portail": "achat",
+ "workspace": "OTO Achat",
+ "custom_block": "OTOIA Chat · OTO Achat",
+ "html_id": "otoia-chat-achat",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "roles_allowed": [
+ "OTO Achat Acheteur",
+ "OTO Achat Directeur",
+ "OTO Achat Fournisseurs",
+ "OTO Achat Magasinier",
+ "OTO Achat Réception"
+ ],
+ "knowledge_scope": [
+ "Contact",
+ "Item",
+ "Material Request",
+ "Purchase Order",
+ "Purchase Receipt",
+ "Request for Quotation",
+ "Stock Entry",
+ "Supplier"
+ ],
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint": null
+ },
+ {
+ "portail": "compta",
+ "workspace": "OTO Compta",
+ "custom_block": "OTOIA Chat · OTO Compta",
+ "html_id": "otoia-chat-compta",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "roles_allowed": [
+ "OTO Compta Audit UAF",
+ "OTO Compta Clients AR",
+ "OTO Compta Contrôle Gestion",
+ "OTO Compta Fiscaliste eCF",
+ "OTO Compta Fournisseurs AP",
+ "OTO Compta Général",
+ "OTO Compta Paie",
+ "OTO Compta Trésorier"
+ ],
+ "knowledge_scope": [
+ "Account",
+ "Bank Transaction",
+ "Budget",
+ "Cost Center",
+ "Employee",
+ "GL Entry",
+ "Journal Entry",
+ "Payment Entry",
+ "Purchase Invoice",
+ "Salary Slip",
+ "Sales Invoice"
+ ],
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint": null
+ },
+ {
+ "portail": "direction",
+ "workspace": "OTO Direction",
+ "custom_block": "OTOIA Chat · OTO Direction",
+ "html_id": "otoia-chat-direction",
+ "persona": {
+ "nom": "Amélie",
+ "voix": "multilingual_v2"
+ },
+ "langues": [
+ "fr",
+ "en",
+ "es"
+ ],
+ "lang_defaut": "fr",
+ "ui_label": {
+ "fr": "Assistant OTOIA",
+ "en": "OTOIA Assistant",
+ "es": "Asistente OTOIA"
+ },
+ "roles_allowed": [
+ "OTO Conseil Administration",
+ "OTO Direction Commerciale",
+ "OTO Direction Financière",
+ "OTO Direction Générale",
+ "OTO Direction Opérations",
+ "OTO Direction Président",
+ "OTO Faisabilité Analyste",
+ "OTO Legal Directeur",
+ "OTO Legal ONAPI"
+ ],
+ "knowledge_scope": [
+ "CONFOTUR Application",
+ "Contract",
+ "Faisabilité",
+ "Journal Entry",
+ "Lead",
+ "Opportunity",
+ "Payment Entry",
+ "Project",
+ "Purchase Invoice",
+ "Purchase Order",
+ "Quotation",
+ "Sales Invoice",
+ "Sales Order",
+ "Task"
+ ],
+ "capabilities": [
+ "aec.py",
+ "knowledge.py",
+ "prompt_engine.py",
+ "chat.py"
+ ],
+ "endpoint": null
+ }
+]
diff --git a/05_deliverables_mvp/frontend/chat_otoia/out/custom_block.json b/05_deliverables_mvp/frontend/chat_otoia/out/custom_block.json
new file mode 100644
index 0000000..2d459b4
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/out/custom_block.json
@@ -0,0 +1,27 @@
+[
+ {
+ "doctype": "Custom Block",
+ "block_name": "OTOIA Chat · OTO Ventes",
+ "html": "
"
+ },
+ {
+ "doctype": "Custom Block",
+ "block_name": "OTOIA Chat · OTO Construction",
+ "html": "
"
+ },
+ {
+ "doctype": "Custom Block",
+ "block_name": "OTOIA Chat · OTO Achat",
+ "html": "
"
+ },
+ {
+ "doctype": "Custom Block",
+ "block_name": "OTOIA Chat · OTO Compta",
+ "html": "
"
+ },
+ {
+ "doctype": "Custom Block",
+ "block_name": "OTOIA Chat · OTO Direction",
+ "html": "
"
+ }
+]
diff --git a/05_deliverables_mvp/frontend/chat_otoia/tests/test_chat_otoia.py b/05_deliverables_mvp/frontend/chat_otoia/tests/test_chat_otoia.py
new file mode 100644
index 0000000..47256b7
--- /dev/null
+++ b/05_deliverables_mvp/frontend/chat_otoia/tests/test_chat_otoia.py
@@ -0,0 +1,237 @@
+"""Tests du générateur Chat OTOIA embarqué par portail (Sprint 6 · ERPNext Backend).
+
+Stdlib pur (unittest) — aucune dépendance pip. Couvre :
+ - conformité au schéma + les 14 invariants du CLI (chemin nominal) ;
+ - la cross-cohérence RBAC ↔ portails ↔ Workspaces ↔ seo ;
+ - l'anti-invention (#6) par injections négatives (endpoint fabriqué, portée
+ élargie, persona altérée, capability ajoutée, langue inventée, chiffre/URL
+ dans le HTML de montage, rôles désynchronisés des Workspaces).
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import os
+import sys
+import unittest
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_ROOT = os.path.normpath(os.path.join(_HERE, ".."))
+sys.path.insert(0, _ROOT)
+
+import chat_otoia_gen as gen # noqa: E402
+from chatlib import builder, deps, frappe, knowledge # noqa: E402
+
+
+def build():
+ return gen._build_bundle()
+
+
+class TestNominal(unittest.TestCase):
+ def setUp(self):
+ self.bundle = build()
+
+ def test_build_valide(self):
+ self.assertEqual(gen._validate_bundle(self.bundle), [])
+
+ def test_schema_seul(self):
+ schema = deps.load_json(deps.SCHEMA_PATH)
+ self.assertEqual(deps.validate(self.bundle, schema), [])
+
+ def test_cinq_portails_metier(self):
+ contract = deps.load_contract()
+ keys = [m["portail"] for m in self.bundle["chat_mount"]]
+ self.assertEqual(set(keys), set(contract["portails_business"]))
+ self.assertEqual(len(keys), 5)
+ self.assertNotIn("plateforme", keys) # console technique exclue
+
+ def test_un_block_un_mount_par_portail(self):
+ self.assertEqual(len(self.bundle["custom_block"]), 5)
+ self.assertEqual(len(self.bundle["chat_mount"]), 5)
+ names = [cb["block_name"] for cb in self.bundle["custom_block"]]
+ self.assertEqual(len(names), len(set(names)))
+
+ def test_carrier_natif_custom_block(self):
+ for cb in self.bundle["custom_block"]:
+ self.assertEqual(cb["doctype"], "Custom Block")
+
+ def test_ordre_deterministe(self):
+ contract = deps.load_contract()
+ spec = deps.load_spec()
+ pspec = deps.load_portails_spec()
+ langs, dflt = deps.load_langs()
+ b2 = builder.build_bundle(contract, spec, pspec, langs, dflt)
+ self.assertEqual(
+ json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
+ json.dumps(b2, sort_keys=True, ensure_ascii=False),
+ )
+
+
+class TestAncrageRBAC(unittest.TestCase):
+ def setUp(self):
+ self.bundle = build()
+ self.contract = deps.load_contract()
+
+ def test_knowledge_scope_egale_surface_rbac(self):
+ for m in self.bundle["chat_mount"]:
+ attendu = knowledge.knowledge_scope_for(self.contract, m["portail"])
+ self.assertEqual(m["knowledge_scope"], attendu)
+ self.assertGreater(len(attendu), 0)
+
+ def test_roles_allowed_egale_roles_portail(self):
+ for m in self.bundle["chat_mount"]:
+ attendu = knowledge.roles_for(self.contract, m["portail"])
+ self.assertEqual(m["roles_allowed"], attendu)
+
+ def test_roles_synchronises_avec_workspaces(self):
+ ws_roles = gen._workspace_roles(deps.load_workspaces())
+ for m in self.bundle["chat_mount"]:
+ self.assertIn(m["workspace"], ws_roles)
+ self.assertEqual(m["roles_allowed"], ws_roles[m["workspace"]])
+
+ def test_44_roles_couverts(self):
+ # même total restreint que les Workspaces (hand-off portails).
+ total = sum(len(m["roles_allowed"]) for m in self.bundle["chat_mount"])
+ self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
+ self.assertEqual(total, 44)
+
+
+class TestPersonaCapacitesLangues(unittest.TestCase):
+ def setUp(self):
+ self.bundle = build()
+
+ def test_persona_amelie_sourcee(self):
+ spec = deps.load_spec()
+ self.assertEqual(spec["persona"]["nom"], "Amélie")
+ self.assertEqual(spec["persona"]["voix"], "multilingual_v2")
+ self.assertIn("CLAUDE.md", spec["persona"]["source"])
+ for m in self.bundle["chat_mount"]:
+ self.assertEqual(m["persona"], {"nom": "Amélie", "voix": "multilingual_v2"})
+
+ def test_capabilities_sourcees_sans_ajout(self):
+ spec = deps.load_spec()
+ caps = [c["module"] for c in spec["capabilities"]]
+ self.assertEqual(caps, ["aec.py", "knowledge.py", "prompt_engine.py", "chat.py"])
+ for c in spec["capabilities"]:
+ self.assertIn("CLAUDE.md", c["source"])
+ for m in self.bundle["chat_mount"]:
+ self.assertEqual(m["capabilities"], caps)
+
+ def test_langues_derivees_du_seo(self):
+ langs, dflt = deps.load_langs()
+ self.assertEqual(langs, ["fr", "en", "es"])
+ for m in self.bundle["chat_mount"]:
+ self.assertEqual(m["langues"], langs)
+ self.assertEqual(m["lang_defaut"], dflt)
+
+
+class TestAntiInvention(unittest.TestCase):
+ """#6 : endpoint jamais fabriqué, aucun chiffre/URL, aucune portée élargie."""
+
+ def setUp(self):
+ self.bundle = build()
+
+ def test_endpoint_null_partout(self):
+ for m in self.bundle["chat_mount"]:
+ self.assertIsNone(m["endpoint"])
+ self.assertEqual(self.bundle["manifest"]["endpoint_statut"], "a_confirmer")
+ self.assertIsNone(deps.load_spec()["endpoint"]["valeur"])
+
+ def test_html_sans_url_ni_chiffre(self):
+ for cb in self.bundle["custom_block"]:
+ html = cb["html"]
+ self.assertNotIn("http://", html)
+ self.assertNotIn("https://", html)
+ self.assertFalse(any(ch.isdigit() for ch in html))
+
+ def test_html_id_et_block_name_deterministes(self):
+ labels = {p["key"]: p["label"] for p in deps.load_portails_spec()["portails"]}
+ for m in self.bundle["chat_mount"]:
+ self.assertEqual(m["html_id"], f"otoia-chat-{m['portail']}")
+ self.assertEqual(m["custom_block"], f"OTOIA Chat · {labels[m['portail']]}")
+
+ def test_brand_verbatim(self):
+ b = self.bundle["manifest"]["brand"]
+ self.assertEqual(b["fond"]["valeur"], "#0a0a12")
+ self.assertEqual(b["accent"]["valeur"], "#f0b429")
+ polices = [p["valeur"] for p in b["polices"]]
+ self.assertIn("Fraunces", polices)
+ self.assertIn("Cormorant Garamond", polices)
+
+ # --- injections négatives : chaque altération DOIT être rejetée ---
+
+ def _expect_error(self, mutate):
+ bundle = copy.deepcopy(self.bundle)
+ mutate(bundle)
+ errors = gen._validate_bundle(bundle)
+ self.assertNotEqual(errors, [], "une altération n'a pas été détectée")
+
+ def test_rejet_endpoint_fabrique(self):
+ self._expect_error(lambda b: b["chat_mount"][0].__setitem__("endpoint", "https://otoia.local"))
+
+ def test_rejet_portee_elargie(self):
+ # ajoute un DocType hors surface RBAC du portail.
+ self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].append("Salary Slip"))
+
+ def test_rejet_portee_reduite(self):
+ self._expect_error(lambda b: b["chat_mount"][0]["knowledge_scope"].pop())
+
+ def test_rejet_role_desynchronise(self):
+ self._expect_error(lambda b: b["chat_mount"][0]["roles_allowed"].append("OTO Fantome"))
+
+ def test_rejet_persona_alteree(self):
+ self._expect_error(lambda b: b["chat_mount"][0].__setitem__("persona", {"nom": "Bob", "voix": "x"}))
+
+ def test_rejet_capability_ajoutee(self):
+ self._expect_error(lambda b: b["chat_mount"][0]["capabilities"].append("exfil.py"))
+
+ def test_rejet_langue_inventee(self):
+ self._expect_error(lambda b: b["chat_mount"][0]["langues"].append("de"))
+
+ def test_rejet_html_avec_chiffre(self):
+ def m(b):
+ cb = b["custom_block"][0]
+ cb["html"] = cb["html"].replace(">
", " data-x=\"9\">
")
+ self._expect_error(m)
+
+ def test_rejet_html_avec_url(self):
+ def m(b):
+ cb = b["custom_block"][0]
+ cb["html"] = cb["html"].replace(">
", " data-src=\"https://x\">
")
+ self._expect_error(m)
+
+ def test_rejet_plateforme_incluse(self):
+ def m(b):
+ extra = copy.deepcopy(b["chat_mount"][0])
+ extra["portail"] = "plateforme"
+ b["chat_mount"].append(extra)
+ self._expect_error(m)
+
+ def test_rejet_count_incoherent(self):
+ self._expect_error(lambda b: b["manifest"]["counts"].__setitem__("roles_couverts", 999))
+
+
+class TestManifest(unittest.TestCase):
+ def setUp(self):
+ self.bundle = build()
+
+ def test_counts_coherents(self):
+ c = self.bundle["manifest"]["counts"]
+ self.assertEqual(c["portails"], 5)
+ self.assertEqual(c["custom_blocks"], 5)
+ self.assertEqual(c["mounts"], 5)
+ union = sorted({dt for m in self.bundle["chat_mount"] for dt in m["knowledge_scope"]})
+ self.assertEqual(c["knowledge_doctypes_uniques"], len(union))
+
+ def test_carrier_natif_documente(self):
+ car = self.bundle["manifest"]["carrier"]
+ self.assertEqual(car["doctype"], "Custom Block")
+ self.assertEqual(car["workspace_content_block_type"], "custom_block")
+
+ def test_handoff_non_vide(self):
+ self.assertTrue(self.bundle["manifest"]["handoff_vps"])
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)