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