From c6bf930fabb22800ba4e0340e4f427c928fff2b8 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Wed, 18 Mar 2026 15:09:51 +0100 Subject: [PATCH] anonymous mode --- .cursor/pousse-commit-msg-lecoffreio.txt | 54 +++++++ ai_working_help/business-qa/anon/anonymize.js | 85 +++++++++++ ai_working_help/business-qa/api.js | 82 ++++++++++ .../business-qa/config/default.json | 26 ++++ .../business-qa/example/index.html | 142 ++++++++++++++++++ ai_working_help/business-qa/interfaces.md | 88 +++++++++++ .../business-qa/recontext/recontextualize.js | 37 +++++ ai_working_help/docs/notary-ai-api.md | 4 +- ai_working_help/server.js | 21 ++- gitea-issues/wiki-migrate-docs.sh | 2 +- 10 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 .cursor/pousse-commit-msg-lecoffreio.txt create mode 100644 ai_working_help/business-qa/anon/anonymize.js create mode 100644 ai_working_help/business-qa/api.js create mode 100644 ai_working_help/business-qa/config/default.json create mode 100644 ai_working_help/business-qa/example/index.html create mode 100644 ai_working_help/business-qa/interfaces.md create mode 100644 ai_working_help/business-qa/recontext/recontextualize.js 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"