[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:
+21
-1
@@ -321,10 +321,30 @@ jobs:
|
|||||||
- name: Tests générateur SEO
|
- name: Tests générateur SEO
|
||||||
run: python3 -m unittest discover -s tests -v
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Chat OTOIA embarqué (Sprint 6 · ERPNext Backend · roadmap L63 « Chat OTOIA
|
||||||
|
# embedded dans chaque portail »). Un `Custom Block` natif v15 (#1) par portail
|
||||||
|
# métier, inséré dans le `content` du Workspace, + sa config runtime (persona
|
||||||
|
# Amélie, langues seo, rôles autorisés, portée de connaissance). Cross-cohérence
|
||||||
|
# Chat ↔ RBAC ↔ Workspaces : `roles_allowed`/`knowledge_scope` = surface RBAC
|
||||||
|
# EXACTE du portail (mêmes valeurs que les Has Role des Workspaces) ; endpoint
|
||||||
|
# OTOIA = null (a_confirmer · #6/#8). Schéma de sortie + 14 invariants. stdlib pur.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
chat-otoia-tests:
|
||||||
|
name: Chat OTOIA · montage par portail (Custom Block)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: 05_deliverables_mvp/frontend/chat_otoia
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Tests générateur Chat OTOIA
|
||||||
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
gate:
|
gate:
|
||||||
name: Gate qualité (agrégat)
|
name: Gate qualité (agrégat)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests]
|
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests]
|
||||||
steps:
|
steps:
|
||||||
- name: Résultat
|
- name: Résultat
|
||||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
# Activity Log · 2026-07-30 · Claude Code DTP
|
# Activity Log · 2026-07-30 · Claude Code DTP
|
||||||
|
|
||||||
|
## Session `20260730_092724` (session 19)
|
||||||
|
|
||||||
|
**Tâche** : **Sprint 6 · ERPNext Backend** — Générateur du **Chat OTOIA embarqué
|
||||||
|
par portail** (roadmap **L63** « ERPNext Backend : **Chat OTOIA embedded dans chaque
|
||||||
|
portail** »). Deuxième volet Sprint 6 réalisable en repo après le SEO trilingue
|
||||||
|
(session 18) ; le volet OTOIA voice Amélie (pilote AEC) dépend d'API externes / desk
|
||||||
|
VPS (hors périmètre worker · #8).
|
||||||
|
|
||||||
|
**Décision d'architecture (#1 ERPNext natif)** : dans ERPNext v15, un bloc de contenu
|
||||||
|
réutilisable inséré dans un `Workspace` **EST** le DocType `Custom Block` → on livre
|
||||||
|
**un `Custom Block` par portail** (un `<div>` de montage) + la **config runtime**
|
||||||
|
(`chat_mount.json`), pas de framework de chat externe. Chaque bloc s'ancre sur le
|
||||||
|
`Workspace` du portail (hand-off Sprint 4 · `frontend/portails`).
|
||||||
|
|
||||||
|
**Fichiers créés** — `05_deliverables_mvp/frontend/chat_otoia/` :
|
||||||
|
- `chat_spec.json` (présentation + persona seules · persona/capabilities/marque
|
||||||
|
sourcées · `ui_label` FR/EN/ES · **endpoint `null`**)
|
||||||
|
- `chatlib/{__init__,deps,frappe,knowledge,builder}.py` (`deps` **réutilise** le
|
||||||
|
validateur + branding Publiciste **et** le builder RBAC↔Workspaces des portails,
|
||||||
|
et dérive les langues du `seo_spec` ; `knowledge` dérive rôles + portée du CONTRAT ;
|
||||||
|
`builder` déterministe)
|
||||||
|
- `chat_otoia_gen.py` (CLI `build`/`validate` · **14 invariants**)
|
||||||
|
- `chat.schema.json` (contrat de sortie draft-07)
|
||||||
|
- `out/{custom_block,chat_mount,MANIFEST}.json` (hand-off) · `tests/test_chat_otoia.py`
|
||||||
|
(**31 tests** dont 11 injections négatives) · `README.md` · `.gitignore`
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `.gitea/workflows/ci.yml` : job `chat-otoia-tests` + ajout au `gate`.
|
||||||
|
|
||||||
|
**Anti-invention (cœur · #6)** : **endpoint OTOIA = `null` (`a_confirmer`)** — jamais
|
||||||
|
fabriqué (invariant refuse tout endpoint non-null et toute URL dans le HTML) ; **portée
|
||||||
|
de connaissance = surface RBAC EXACTE du portail** (l'assistant ne peut exposer un
|
||||||
|
DocType hors périmètre — ajout/retrait rejeté) ; **rôles autorisés synchronisés avec
|
||||||
|
les Has Role réels des Workspaces** (cohérence inter-livrables prouvée) ; persona
|
||||||
|
Amélie / capabilities OTOIA / langues repris de sources sourcées ; HTML de montage sans
|
||||||
|
aucun chiffre.
|
||||||
|
|
||||||
|
**Résultat** : 5 portails métier (`plateforme` exclu) · 5 `Custom Block` · **44 rôles
|
||||||
|
couverts** · **35 DocTypes de connaissance uniques** · endpoint `a_confirmer`.
|
||||||
|
|
||||||
|
**Vérifs** : 31/31 tests ; gate CI local vert (guard + JSON + docs + YAML) ;
|
||||||
|
régression **408 tests verts** au total (377 → +31) ; build déterministe.
|
||||||
|
|
||||||
|
**Hors périmètre worker (VPS · #8)** : import fixtures `Custom Block` + insertion du
|
||||||
|
bloc `custom_block` dans le `content` de chaque Workspace + renseignement endpoint
|
||||||
|
OTOIA + chargement du web-component via le thème desk → agents ERPNext Backend /
|
||||||
|
Frontend.
|
||||||
|
|
||||||
|
**Détail complet** : voir
|
||||||
|
[`05_deliverables_mvp/daily_reports/2026-07-30-session19.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session19.md).
|
||||||
|
|
||||||
|
**Auto-score 4Big** : 96/100.
|
||||||
|
|
||||||
## Session `20260730_085719` (session 18)
|
## Session `20260730_085719` (session 18)
|
||||||
|
|
||||||
**Tâche** : **Sprint 6 · SEO** — Générateur **SEO trilingue** (roadmap L60 :
|
**Tâche** : **Sprint 6 · SEO** — Générateur **SEO trilingue** (roadmap L60 :
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Rapport de session · 2026-07-30 · session 19 (`20260730_092724`)
|
||||||
|
|
||||||
|
## Tâche
|
||||||
|
|
||||||
|
**Sprint 6 · ERPNext Backend** — Générateur du **Chat OTOIA embarqué par portail**
|
||||||
|
(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` l.63 : « ERPNext Backend : **Chat
|
||||||
|
OTOIA embedded dans chaque portail** »).
|
||||||
|
|
||||||
|
C'est le **deuxième volet réalisable en repo** de Sprint 6, après le SEO trilingue
|
||||||
|
(session 18). Le troisième volet Sprint 6 — **OTOIA voice Amélie pilote AEC complet**
|
||||||
|
(BIM/Faisabilité) — dépend d'API externes / desk VPS et reste hors périmètre worker
|
||||||
|
(#8). Le volet « chat embarqué » a en revanche une **tranche repo nette** : le
|
||||||
|
**carrier natif** et sa **config de montage** par portail, indépendants du backend
|
||||||
|
conversationnel réel.
|
||||||
|
|
||||||
|
## Décision d'architecture (#1 ERPNext natif)
|
||||||
|
|
||||||
|
Dans ERPNext v15, un bloc de contenu réutilisable inséré dans un `Workspace` **EST**
|
||||||
|
le DocType `Custom Block` (`frappe/desk/doctype/custom_block`). On ne fabrique donc
|
||||||
|
**aucun** framework de chat externe : on livre **un `Custom Block` par portail** (un
|
||||||
|
`<div>` de montage vide et déterministe) + la **config runtime** (`chat_mount.json`)
|
||||||
|
que le web-component OTOIA consommera. Chaque `Custom Block` s'ancre sur le `Workspace`
|
||||||
|
du portail produit par le hand-off Sprint 4 (`frontend/portails`).
|
||||||
|
|
||||||
|
## Fichiers créés — `05_deliverables_mvp/frontend/chat_otoia/`
|
||||||
|
|
||||||
|
- `chat_spec.json` — présentation + persona seules (persona Amélie sourcée, capabilities
|
||||||
|
OTOIA sourcées, `ui_label` FR/EN/ES générique, `endpoint: null`, tokens de marque).
|
||||||
|
- `chatlib/{__init__,deps,frappe,knowledge,builder}.py` :
|
||||||
|
- `deps` **réutilise** (#5) le validateur + `branding` Publiciste **et** le builder
|
||||||
|
RBAC↔Workspaces (`frontend/portails/wslib/builder.py`) → même surface RBAC, zéro
|
||||||
|
duplication ; dérive les langues du `seo_spec.json` (source unique).
|
||||||
|
- `frappe` : connaissance du DocType natif `Custom Block` (`block_name` + `html`).
|
||||||
|
- `knowledge` : `roles_allowed` / `knowledge_scope` par portail, dérivés du CONTRAT.
|
||||||
|
- `builder` : assemblage déterministe du bundle.
|
||||||
|
- `chat_otoia_gen.py` — CLI `build`/`validate` · **14 invariants**.
|
||||||
|
- `chat.schema.json` — contrat de sortie draft-07.
|
||||||
|
- `out/{custom_block,chat_mount,MANIFEST}.json` — hand-off.
|
||||||
|
- `tests/test_chat_otoia.py` (**31 tests** dont 11 injections négatives) · `README.md`
|
||||||
|
· `.gitignore`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `.gitea/workflows/ci.yml` : job `chat-otoia-tests` + ajout au `gate`.
|
||||||
|
|
||||||
|
## Anti-invention (cœur · #6)
|
||||||
|
|
||||||
|
Le générateur ne fabrique **aucun fait** :
|
||||||
|
- **endpoint OTOIA = `null` (`a_confirmer`)** — jamais fabriqué ; un invariant refuse
|
||||||
|
tout endpoint non-null et refuse toute URL (`http(s)://`) dans le HTML de montage.
|
||||||
|
- **portée de connaissance = surface RBAC EXACTE du portail** (`permissions_cibles`) :
|
||||||
|
l'assistant ne peut ni prétendre ni exposer un DocType hors du périmètre du portail.
|
||||||
|
Ajouter/retirer un DocType est rejeté par invariant.
|
||||||
|
- **rôles autorisés = rôles du portail**, et un invariant vérifie qu'ils sont
|
||||||
|
**synchronisés avec les Has Role réels des Workspaces** (`portails/out/workspace.json`)
|
||||||
|
→ cohérence inter-livrables prouvée.
|
||||||
|
- persona (`Amélie`/`multilingual_v2`), capabilities (`aec/knowledge/prompt_engine/chat.py`)
|
||||||
|
et langues (FR/EN/ES) repris de sources **sourcées** (CLAUDE.md, seo_spec), jamais
|
||||||
|
inventés ; le HTML de montage ne contient **aucun chiffre**.
|
||||||
|
|
||||||
|
## Résultat
|
||||||
|
|
||||||
|
5 portails métier (`plateforme` exclu comme les Workspaces) · 5 `Custom Block` ·
|
||||||
|
**44 rôles couverts** (= total restreint des Workspaces) · **35 DocTypes de
|
||||||
|
connaissance uniques** · endpoint `a_confirmer`.
|
||||||
|
|
||||||
|
## Vérifs
|
||||||
|
|
||||||
|
- **31/31** tests du livrable ; `validate` = schéma + 14 invariants verts ; build
|
||||||
|
déterministe.
|
||||||
|
- Gate CI local vert : guard des contraintes · docs (liens OK) · JSON bien formés.
|
||||||
|
- **Régression : 408 tests verts au total** (377 → +31).
|
||||||
|
|
||||||
|
## Hors périmètre worker (VPS · #8)
|
||||||
|
|
||||||
|
Import des fixtures `Custom Block` (bench) · insertion d'un bloc `custom_block` dans
|
||||||
|
le `content` de chaque Workspace (payload editor.js `a_confirmer` selon patch v15) ·
|
||||||
|
renseignement de l'endpoint OTOIA + chargement du web-component via le thème desk ·
|
||||||
|
application des tokens dark+doré → agents ERPNext Backend / Frontend.
|
||||||
|
|
||||||
|
## Auto-score 4Big : 96/100.
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user