[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
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:
@@ -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()))
|
||||
Reference in New Issue
Block a user