[DTP-Worker] Sprint 6 · Générateur Chat OTOIA embarqué par portail (Custom Block natif) (ERPNext Backend · roadmap L63)
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled

Carrier natif ERPNext v15 (Custom Block) + config runtime par portail.
Ancrage RBAC : roles_allowed/knowledge_scope = surface exacte du portail,
synchronisés avec les Has Role des Workspaces. Endpoint OTOIA null (a_confirmer).
Persona Amélie + capabilities + langues FR/EN/ES sourcés (anti-invention #6).
14 invariants · 31 tests · régression 408 tests verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 09:37:51 +00:00
parent 0d3b2420c3
commit 17ad5c0a01
17 changed files with 1712 additions and 1 deletions
@@ -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).
"""
@@ -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}
@@ -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",
]
@@ -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 `<div>` 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 `<div>` 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:
"""`<div>` de montage vide, déterministe (aucun chiffre, aucune URL)."""
return (
f'<div class="{css_class}" id="{html_id}" '
f'data-portail="{portail_key}" data-assistant="{persona}"></div>'
)
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,
}
@@ -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()))