diff --git a/.cursor/pousse-commit-msg-lecoffreio.txt b/.cursor/pousse-commit-msg-lecoffreio.txt
new file mode 100644
index 0000000..2620392
--- /dev/null
+++ b/.cursor/pousse-commit-msg-lecoffreio.txt
@@ -0,0 +1,54 @@
+fix-lint backend 76 errors (target 75 reached); N0 161 → N_final 85
+
+**Initial state:**
+- Backend ESLint had 161 errors (N0). Target: reduce by at least 75 (N_final ≤ 86).
+- Violations: max-params, max-lines, max-lines-per-function, complexity.
+
+**Motivation for change:**
+- Meet project lint rules (max 250 lines/file, 40 lines/function, max 4 params, complexity ≤ 10).
+- Reduce backend error count by ≥75 in one batch.
+
+**Resolution:**
+- 76 errors fixed. N_final = 85. Target ≥75 reached.
+- max-params: introduced options objects in deps (EmailBuilder, AuthService, ZipService, FileProcessingService, FilesNotaryService, DocumentAnchorsService, DocumentWithMergedFilesHelper, DocumentBatchCreationHelper, OfficeFoldersController handlers, NotificationEmailService, AntivirusService, DocumentAnchoringBlockchainHelper, DocumentAnchorsAnchoringHelper, DocumentAnchorsNotaryHelper, IdNotOfficeService, OfficeFolderAnchorsService, FileProcessingStepHelper, FilesService, DocumentAnchoringFinalizationHelper, AggregatedCertificateService, IdNotService, CustomersService, NotaryFolderAIService, etc.).
+- max-lines: extractions (ThirdPartyUploadPermissionHelper, HealthChecksServiceUtils/AnchorHelper, UserOfficeAffiliationsLicenseFilterHelper, IdNotDirectoryApiSearchHelper, DocumentBatchAnchoringErrorHelper, etc.).
+- complexity / max-lines-per-function: extracted helpers (DocumentAnchoringFinalizationHelper, DocumentBatchProofDataHelper, IdNotRoleService, MailchimpService, RolePermissionsMatrixService, FolderBusinessService, OfficeFolderAnchorsVerificationHelper, CollaboratorsAggregationService, WatermarkBufferProcessorHelper, MailchimpEmailSenderHelper, IpfsService, IdNotDirectoryService, DeedTypeListSettingsService, DocumentRemindersService, DocumentAnchoringWatermarkHelper, AnchorCertificatePdfDrawingHelpers, IdNotSiteBaseSearchHelper, DocumentWithMergedFilesHelper).
+
+**Root cause:**
+- Files over 250 lines; functions over 40 lines; methods with >4 params; cyclomatic complexity >10.
+
+**Features impacted:**
+- None; refactor only. No API or behaviour change.
+
+**Code modified:**
+- Backend: services, controllers, helpers (option objects, extracted helpers, new helper files). Frontend: shared.tsx (folders) alignment.
+
+**Documentation modified:**
+- CHANGELOG.md (2.0.63). docs/fixKnowledge/fix-lint-75-errors-batch-2026-03.md (content migrated to wiki via docupdate).
+
+**Configurations modified:**
+- None.
+
+**Deploy files modified:**
+- deploy/scripts_v2/deploy.sh (if any change present).
+
+**Log files impacted:**
+- None.
+
+**Databases and other sources modified:**
+- None.
+
+**Changes outside project:**
+- None.
+
+**Files in .cursor/ modified:**
+- .cursor/agents/change-to-all-branches.md, .cursor/agents/fix-lint.md.
+
+**Files in .secrets/ modified:**
+- None.
+
+**New patch version in VERSION:**
+- 2.0.63.
+
+**CHANGELOG.md updated:**
+- Yes.
diff --git a/ai_working_help/business-qa/anon/anonymize.js b/ai_working_help/business-qa/anon/anonymize.js
new file mode 100644
index 0000000..e4dc683
--- /dev/null
+++ b/ai_working_help/business-qa/anon/anonymize.js
@@ -0,0 +1,85 @@
+/**
+ * Anonymize payload (folder_context, question) by replacing PII with placeholders.
+ * Returns anonymized payload and a mapping for recontextualization.
+ * Config: { anonymizeKeys: string[], placeholderPrefix: string, recursive: boolean, anonymizeQuestion: boolean }
+ * @param {object} payload - { folder_context?: object, question?: string, ... }
+ * @param {object} config - from business-qa/config
+ * @returns {{ anonymizedPayload: object, mapping: Array<{ placeholder: string, value: string }> }}
+ */
+function anonymize(payload, config) {
+ const mapping = [];
+ const placeholderPrefix = (config && config.placeholderPrefix) || "PII";
+ const anonymizeKeys = (config && config.anonymizeKeys) || [];
+ const recursive = config && config.recursive !== false;
+ const anonymizeQuestion = config && config.anonymizeQuestion !== false;
+
+ function nextPlaceholder() {
+ return `${placeholderPrefix}_${mapping.length}`;
+ }
+
+ function addToMapping(value) {
+ if (value === null || value === undefined || value === "") return value;
+ const s = String(value).trim();
+ if (!s) return value;
+ const existing = mapping.find((m) => m.value === s);
+ if (existing) return existing.placeholder;
+ const placeholder = nextPlaceholder();
+ mapping.push({ placeholder, value: s });
+ return placeholder;
+ }
+
+ function anonymizeValue(val, key) {
+ if (val === null || val === undefined) return val;
+ if (Array.isArray(val)) {
+ return val.map((item) => anonymizeValue(item, key));
+ }
+ if (typeof val === "object") {
+ return anonymizeObject(val);
+ }
+ if (typeof val === "string" && anonymizeKeys.includes(key)) {
+ return addToMapping(val);
+ }
+ if (typeof val === "number" || typeof val === "boolean") return val;
+ if (typeof val === "string") return val;
+ return val;
+ }
+
+ function anonymizeObject(obj) {
+ if (obj === null || typeof obj !== "object") return obj;
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ const key = k;
+ if (recursive && (Array.isArray(v) || (v && typeof v === "object"))) {
+ out[key] = anonymizeValue(v, key);
+ } else if (anonymizeKeys.includes(key) && typeof v === "string") {
+ out[key] = addToMapping(v);
+ } else {
+ out[key] = anonymizeValue(v, key);
+ }
+ }
+ return out;
+ }
+
+ const anonymizedPayload = { ...payload };
+
+ if (payload.folder_context && typeof payload.folder_context === "object") {
+ anonymizedPayload.folder_context = anonymizeObject(payload.folder_context);
+ }
+
+ if (anonymizeQuestion && payload.question && typeof payload.question === "string") {
+ let q = payload.question;
+ for (const m of mapping) {
+ const re = new RegExp(escapeRegex(m.value), "g");
+ q = q.replace(re, m.placeholder);
+ }
+ anonymizedPayload.question = q;
+ }
+
+ return { anonymizedPayload, mapping };
+}
+
+function escapeRegex(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+module.exports = { anonymize };
diff --git a/ai_working_help/business-qa/api.js b/ai_working_help/business-qa/api.js
new file mode 100644
index 0000000..37e65d8
--- /dev/null
+++ b/ai_working_help/business-qa/api.js
@@ -0,0 +1,82 @@
+/**
+ * Business-QA module API: anonymize and recontextualize endpoints.
+ * Mount at /v1/business-qa. Used by example page and tooling.
+ */
+const express = require("express");
+const path = require("path");
+const fs = require("fs");
+const { anonymize } = require("./anon/anonymize");
+const { recontextualizeText, recontextualizeResponse } = require("./recontext/recontextualize");
+
+const router = express.Router();
+const CONFIG_DIR = path.join(__dirname, "config");
+router.use("/example", express.static(path.join(__dirname, "example")));
+
+/**
+ * Load config by name (e.g. "default" -> config/default.json).
+ * @param {string} name
+ * @returns {object}
+ */
+function loadConfig(name) {
+ const safe = name.replace(/[^a-zA-Z0-9-_]/g, "") || "default";
+ const p = path.join(CONFIG_DIR, `${safe}.json`);
+ if (!fs.existsSync(p)) return {};
+ try {
+ return JSON.parse(fs.readFileSync(p, "utf8"));
+ } catch (e) {
+ return {};
+ }
+}
+
+router.get("/config/:name", (req, res) => {
+ const config = loadConfig(req.params.name || "default");
+ res.status(200).json(config);
+});
+
+router.post("/anonymize", (req, res) => {
+ const body = req.body || {};
+ const payload = body.payload;
+ let config = body.config;
+ if (!payload || typeof payload !== "object") {
+ return res.status(400).json({ message: "Missing or invalid payload" });
+ }
+ if (!config || typeof config !== "object") {
+ config = loadConfig(body.configName || "default");
+ }
+ try {
+ const { anonymizedPayload, mapping } = anonymize(payload, config);
+ res.status(200).json({ anonymizedPayload, mapping });
+ } catch (err) {
+ console.error("[business-qa] anonymize error", err);
+ res.status(500).json({ message: "Anonymization failed" });
+ }
+});
+
+router.post("/recontextualize", (req, res) => {
+ const body = req.body || {};
+ const mapping = body.mapping;
+ if (!Array.isArray(mapping)) {
+ return res.status(400).json({ message: "Missing or invalid mapping" });
+ }
+ if (body.response !== undefined) {
+ try {
+ const response = recontextualizeResponse(body.response, mapping);
+ return res.status(200).json({ response });
+ } catch (err) {
+ console.error("[business-qa] recontextualize response error", err);
+ return res.status(500).json({ message: "Recontextualization failed" });
+ }
+ }
+ if (body.text !== undefined) {
+ try {
+ const text = recontextualizeText(body.text, mapping);
+ return res.status(200).json({ text });
+ } catch (err) {
+ console.error("[business-qa] recontextualize text error", err);
+ return res.status(500).json({ message: "Recontextualization failed" });
+ }
+ }
+ res.status(400).json({ message: "Provide response or text" });
+});
+
+module.exports = { router, loadConfig, anonymize, recontextualizeResponse };
diff --git a/ai_working_help/business-qa/config/default.json b/ai_working_help/business-qa/config/default.json
new file mode 100644
index 0000000..ba38efd
--- /dev/null
+++ b/ai_working_help/business-qa/config/default.json
@@ -0,0 +1,26 @@
+{
+ "description": "Default anonymization rules for notary folder context and question text",
+ "anonymizeKeys": [
+ "firstName",
+ "lastName",
+ "first_name",
+ "last_name",
+ "email",
+ "address",
+ "city",
+ "postalCode",
+ "postal_code",
+ "phone",
+ "mobile",
+ "birthDate",
+ "birth_date",
+ "placeOfBirth",
+ "place_of_birth",
+ "name",
+ "label",
+ "comment"
+ ],
+ "placeholderPrefix": "PII",
+ "recursive": true,
+ "anonymizeQuestion": true
+}
diff --git a/ai_working_help/business-qa/example/index.html b/ai_working_help/business-qa/example/index.html
new file mode 100644
index 0000000..ac4970a
--- /dev/null
+++ b/ai_working_help/business-qa/example/index.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+ Business-QA – Anonymisation et recontextualisation
+
+
+
+ Business-QA – Anonymisation et recontextualisation
+ Exemple d’utilisation du module : anonymiser un payload dossier notaire, puis recontextualiser une réponse IA.
+
+
+ 1. Payload (question + folder_context)
+
+
+
+
+
+
+
+ 2. Payload anonymisé
+ —
+
+
+ 3. Mapping (placeholder → valeur)
+ —
+
+
+
+
+ 4. Réponse IA (avec placeholders) à recontextualiser
+
+
+
+
+
+
+ 5. Réponse recontextualisée
+ —
+
+
+
+
+
diff --git a/ai_working_help/business-qa/interfaces.md b/ai_working_help/business-qa/interfaces.md
new file mode 100644
index 0000000..3df9385
--- /dev/null
+++ b/ai_working_help/business-qa/interfaces.md
@@ -0,0 +1,88 @@
+# Business-QA – Interfaces et API
+
+Module d’anonymisation, interrogation IA métier et recontextualisation pour les dossiers notaires dans `ai_working_help`.
+
+## Rôle
+
+1. **Anonymiser / décontextualiser** : remplacer les données à caractère personnel dans le contexte dossier et la question par des placeholders (ex. `PII_0`, `PII_1`), avec conservation d’un mapping pour la restitution.
+2. **Interroger l’IA métier** : le flux existant (spooler pending → agent notary-ai-process → responded) reçoit uniquement les données anonymisées.
+3. **Recontextualiser** : au moment de la restitution (GET response), remplacer les placeholders dans la réponse par les valeurs d’origine à l’aide du mapping.
+
+## Structure du module
+
+```
+business-qa/
+ config/
+ default.json # Règles d’anonymisation (clés à anonymiser, préfixe placeholder)
+ anon/
+ anonymize.js # anonymize(payload, config) → { anonymizedPayload, mapping }
+ recontext/
+ recontextualize.js # recontextualizeText(text, mapping), recontextualizeResponse(response, mapping)
+ api.js # Route Express montée en /v1/business-qa
+ interfaces.md # Ce fichier
+ example/
+ index.html # Page d’exemple
+```
+
+## Configuration (config/default.json)
+
+| Champ | Type | Description |
+|-------|------|-------------|
+| `anonymizeKeys` | string[] | Noms de propriétés à remplacer par un placeholder (ex. firstName, lastName, email, address). |
+| `placeholderPrefix` | string | Préfixe des placeholders générés (ex. "PII" → PII_0, PII_1). |
+| `recursive` | boolean | Si true, parcours récursif des objets imbriqués. |
+| `anonymizeQuestion` | boolean | Si true, les occurrences des valeurs extraites du contexte sont aussi remplacées dans la question. |
+
+Fichiers additionnels dans `config/` (ex. `custom.json`) permettent d’autres jeux de règles ; le nom est passé via `anonConfigName` à l’enqueue.
+
+## API du module
+
+### GET /v1/business-qa/config/:name
+
+Retourne la configuration d’anonymisation par nom (ex. `default` → `config/default.json`).
+
+- **Réponse 200** : `{ ...config }`
+
+### POST /v1/business-qa/anonymize
+
+Anonymise un payload (sans auth, pour outillage / page d’exemple).
+
+- **Body** : `{ payload: object, config?: object, configName?: string }`
+- **Réponse 200** : `{ anonymizedPayload: object, mapping: Array<{ placeholder: string, value: string }> }`
+- **Réponse 400** : payload manquant ou invalide.
+
+### POST /v1/business-qa/recontextualize
+
+Recontextualise un texte ou une réponse complète.
+
+- **Body** : `{ mapping: Array<{ placeholder, value }>, response?: object }` ou `{ mapping, text?: string }`
+- **Réponse 200** : `{ response?: object }` ou `{ text?: string }`
+- **Réponse 400** : mapping manquant ou ni `response` ni `text` fourni.
+
+### Page d’exemple
+
+- **GET /v1/business-qa/example/** : sert `business-qa/example/index.html`.
+
+## Intégration dans le flux notary-ai
+
+### POST /v1/enqueue (avec anonymisation)
+
+- **Body** : même schéma qu’actuellement, avec champs optionnels :
+ - `anon: true` : active l’anonymisation avant écriture dans le spooler.
+ - `anonConfigName?: string` : nom de la config (défaut `"default"`).
+- Comportement : si `anon === true`, appel de `anonymize(payload, config)` ; le payload écrit en pending contient `question` et `folder_context` anonymisés, et une clé `anon_mapping` conservée jusqu’à la réponse. L’agent métier ne reçoit que les données anonymisées (le mapping est ignoré par l’agent).
+
+### GET /v1/response/:request_uid
+
+- Si le fichier responded contient `anon_mapping`, la réponse renvoyée au client est recontextualisée (les 4 champs answer, nextActionsTable, membersInfoSheet, synthesisRecommendation sont traités par `recontextualizeResponse`).
+
+## Contrats (types)
+
+- **Mapping** : `Array<{ placeholder: string, value: string }>`.
+- **Payload enqueue** : `{ request_uid, folder_uid?, office_uid?, user_id?, question, folder_context?, anon?, anonConfigName? }`.
+- **Response** : `{ answer?, nextActionsTable?, membersInfoSheet?, synthesisRecommendation? }`.
+
+## Sécurité
+
+- Les endpoints `/v1/business-qa/anonymize` et `/v1/business-qa/recontextualize` ne sont pas protégés par le token projet (usage outillage / démo). Ne pas y envoyer de données sensibles en production sans protection supplémentaire (réseau, auth dédiée).
+- L’enqueue avec `anon: true` reste protégé par le token (Bearer) comme le reste de l’API.
diff --git a/ai_working_help/business-qa/recontext/recontextualize.js b/ai_working_help/business-qa/recontext/recontextualize.js
new file mode 100644
index 0000000..39ed9ce
--- /dev/null
+++ b/ai_working_help/business-qa/recontext/recontextualize.js
@@ -0,0 +1,37 @@
+/**
+ * Recontextualize text or response object by replacing placeholders with original values.
+ * @param {string} text - String that may contain placeholders (e.g. PII_0, PII_1)
+ * @param {Array<{ placeholder: string, value: string }>} mapping - From anonymize()
+ * @returns {string}
+ */
+function recontextualizeText(text, mapping) {
+ if (text === null || text === undefined || typeof text !== "string") return text;
+ let out = text;
+ for (const m of mapping) {
+ const re = new RegExp(escapeRegex(m.placeholder), "g");
+ out = out.replace(re, m.value);
+ }
+ return out;
+}
+
+/**
+ * Recontextualize full response object (4 fields).
+ * @param {object} response - { answer, nextActionsTable, membersInfoSheet, synthesisRecommendation }
+ * @param {Array<{ placeholder: string, value: string }>} mapping
+ * @returns {object}
+ */
+function recontextualizeResponse(response, mapping) {
+ if (!response || !mapping || mapping.length === 0) return response;
+ const out = { ...response };
+ if (typeof out.answer === "string") out.answer = recontextualizeText(out.answer, mapping);
+ if (typeof out.nextActionsTable === "string") out.nextActionsTable = recontextualizeText(out.nextActionsTable, mapping);
+ if (typeof out.membersInfoSheet === "string") out.membersInfoSheet = recontextualizeText(out.membersInfoSheet, mapping);
+ if (typeof out.synthesisRecommendation === "string") out.synthesisRecommendation = recontextualizeText(out.synthesisRecommendation, mapping);
+ return out;
+}
+
+function escapeRegex(s) {
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+module.exports = { recontextualizeText, recontextualizeResponse };
diff --git a/ai_working_help/docs/notary-ai-api.md b/ai_working_help/docs/notary-ai-api.md
index c59e855..e3070f5 100644
--- a/ai_working_help/docs/notary-ai-api.md
+++ b/ai_working_help/docs/notary-ai-api.md
@@ -29,12 +29,14 @@
## API (serveur Node)
- **POST /v1/enqueue**
- Body : `{ request_uid, folder_uid, office_uid, user_id, question, folder_context }`.
+ Body : `{ request_uid, folder_uid, office_uid, user_id, question, folder_context [, anon?, anonConfigName? ] }`.
+ Si `anon === true`, le payload est anonymisé avant écriture (voir `docs/business-qa-api.md`) : le fichier pending contient alors `question` et `folder_context` anonymisés et une clé `anon_mapping` conservée jusqu’à la réponse ; l’agent ne doit pas utiliser `anon_mapping`.
Réponse **202** : `{ request_uid }`.
Écrit dans `projects//data/notary-ai/pending/.json`. **Authentification** : header `Authorization: Bearer ` obligatoire (le token identifie le projet) ; 401 si absent ou inconnu.
- **GET /v1/response/:request_uid**
Réponse **200** : `{ status: "pending" }` ou `{ status: "responded", response: { answer, nextActionsTable, membersInfoSheet, synthesisRecommendation } }`.
+ Si le fichier responded contient `anon_mapping`, la réponse est recontextualisée avant envoi (voir `docs/business-qa-api.md`).
Lit d’abord `projects//data/notary-ai/responded/`, sinon `pending/`. **Authentification** : idem (token obligatoire).
- **GET /v1/health** et **GET /health** : santé du service (sans authentification).
diff --git a/ai_working_help/server.js b/ai_working_help/server.js
index e71a9e1..dc0b3d4 100644
--- a/ai_working_help/server.js
+++ b/ai_working_help/server.js
@@ -1,6 +1,7 @@
/**
* ai_working_help API server.
* Routes: POST /v1/enqueue, GET /v1/response/:request_uid, GET /health, GET /v1/health.
+ * Business-QA: POST /v1/enqueue with anon:true uses anonymization; GET response recontextualizes when anon_mapping present.
* Project id and env are resolved from the Bearer token by searching all
* projects//.secrets//ia_token files; the matching project id is used for the spooler.
* Spooler: projects//data/notary-ai/{pending,responded}.
@@ -9,9 +10,11 @@ const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs");
const path = require("path");
+const businessQa = require("./business-qa/api");
const app = express();
app.use(bodyParser.json({ limit: "1mb" }));
+app.use("/v1/business-qa", businessQa.router);
const PORT = process.env.AI_WORKING_HELP_PORT || 3020;
const IA_DEV_ROOT = path.resolve(__dirname, "..");
@@ -114,7 +117,7 @@ app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
if (!requestUid || typeof requestUid !== "string") {
return res.status(400).json({ message: "Missing request_uid" });
}
- const payload = {
+ let payload = {
request_uid: requestUid,
folder_uid: body.folder_uid,
office_uid: body.office_uid,
@@ -123,6 +126,15 @@ app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
folder_context: body.folder_context || {},
status: "pending",
};
+ if (body.anon === true) {
+ const config = businessQa.loadConfig(body.anonConfigName || "default");
+ const { anonymizedPayload, mapping } = businessQa.anonymize(payload, config);
+ payload = {
+ ...anonymizedPayload,
+ anon_mapping: mapping,
+ status: "pending",
+ };
+ }
try {
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
if (!fs.existsSync(respondedDir)) fs.mkdirSync(respondedDir, { recursive: true });
@@ -145,9 +157,14 @@ app.get("/v1/response/:request_uid", requireApiTokenAndResolveProject, (req, res
const respondedDir = path.join(dir, "responded");
const foundResponded = findFileByRequestUid(respondedDir, requestUid);
if (foundResponded && foundResponded.data.response) {
+ let response = foundResponded.data.response;
+ const mapping = foundResponded.data.anon_mapping;
+ if (Array.isArray(mapping) && mapping.length > 0) {
+ response = businessQa.recontextualizeResponse(response, mapping);
+ }
return res.status(200).json({
status: "responded",
- response: foundResponded.data.response,
+ response,
});
}
const pendingDir = path.join(dir, "pending");
diff --git a/gitea-issues/wiki-migrate-docs.sh b/gitea-issues/wiki-migrate-docs.sh
index b78e16e..e62030b 100755
--- a/gitea-issues/wiki-migrate-docs.sh
+++ b/gitea-issues/wiki-migrate-docs.sh
@@ -11,7 +11,7 @@ set -euo pipefail
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
REPO_ROOT="${GITEA_ISSUES_DIR}/.."
-DOCS_DIR="${REPO_ROOT}/docs"
+DOCS_DIR="${DOCS_DIR:-${REPO_ROOT}/docs}"
# shellcheck source=lib.sh
source "${GITEA_ISSUES_DIR}/lib.sh"