[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,2 @@
__pycache__/
*.pyc
@@ -0,0 +1,73 @@
# Chat OTOIA embarqué par portail · Sprint 6 · ERPNext Backend
> Roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` · Sprint 6 l.63 —
> « **ERPNext Backend : Chat OTOIA embedded dans chaque portail** ».
Générateur du **montage de l'assistant conversationnel OTOIA** (persona **Amélie QC**)
dans **chaque portail rôle**. Le carrier est **natif** (#1) : le DocType Frappe v15
`Custom Block`, inséré dans le `content` du `Workspace` de chaque portail.
## Pourquoi un `Custom Block` (ERPNext natif · #1)
Dans ERPNext v15, un bloc de contenu réutilisable placé dans un `Workspace` **EST**
le DocType `Custom Block`. On ne fabrique donc **aucun** framework de chat externe :
on livre un `Custom Block` par portail (un `<div>` 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 ; `<div>` 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**.
@@ -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}
}
}
}
}
}
@@ -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())
@@ -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 `<div>` 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."
}
}
@@ -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()))
@@ -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."
]
}
@@ -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
}
]
@@ -0,0 +1,27 @@
[
{
"doctype": "Custom Block",
"block_name": "OTOIA Chat · OTO Ventes",
"html": "<div class=\"otoia-chat-mount\" id=\"otoia-chat-ventes\" data-portail=\"ventes\" data-assistant=\"Amélie\"></div>"
},
{
"doctype": "Custom Block",
"block_name": "OTOIA Chat · OTO Construction",
"html": "<div class=\"otoia-chat-mount\" id=\"otoia-chat-construction\" data-portail=\"construction\" data-assistant=\"Amélie\"></div>"
},
{
"doctype": "Custom Block",
"block_name": "OTOIA Chat · OTO Achat",
"html": "<div class=\"otoia-chat-mount\" id=\"otoia-chat-achat\" data-portail=\"achat\" data-assistant=\"Amélie\"></div>"
},
{
"doctype": "Custom Block",
"block_name": "OTOIA Chat · OTO Compta",
"html": "<div class=\"otoia-chat-mount\" id=\"otoia-chat-compta\" data-portail=\"compta\" data-assistant=\"Amélie\"></div>"
},
{
"doctype": "Custom Block",
"block_name": "OTOIA Chat · OTO Direction",
"html": "<div class=\"otoia-chat-mount\" id=\"otoia-chat-direction\" data-portail=\"direction\" data-assistant=\"Amélie\"></div>"
}
]
@@ -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("></div>", " data-x=\"9\"></div>")
self._expect_error(m)
def test_rejet_html_avec_url(self):
def m(b):
cb = b["custom_block"][0]
cb["html"] = cb["html"].replace("></div>", " data-src=\"https://x\"></div>")
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)