anonymous mode
This commit is contained in:
parent
2f23357460
commit
c6bf930fab
54
.cursor/pousse-commit-msg-lecoffreio.txt
Normal file
54
.cursor/pousse-commit-msg-lecoffreio.txt
Normal file
@ -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.
|
||||||
85
ai_working_help/business-qa/anon/anonymize.js
Normal file
85
ai_working_help/business-qa/anon/anonymize.js
Normal file
@ -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 };
|
||||||
82
ai_working_help/business-qa/api.js
Normal file
82
ai_working_help/business-qa/api.js
Normal file
@ -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 };
|
||||||
26
ai_working_help/business-qa/config/default.json
Normal file
26
ai_working_help/business-qa/config/default.json
Normal file
@ -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
|
||||||
|
}
|
||||||
142
ai_working_help/business-qa/example/index.html
Normal file
142
ai_working_help/business-qa/example/index.html
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Business-QA – Anonymisation et recontextualisation</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #1a1a2e; --card: #16213e; --accent: #0f3460; --text: #e8e8e8; --muted: #a0a0a0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||||
|
section { background: var(--card); border: 1px solid var(--accent); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
section h2 { font-size: 1rem; margin: 0 0 0.5rem; color: var(--muted); }
|
||||||
|
textarea { width: 100%; min-height: 100px; font-family: monospace; font-size: 12px; background: #0d0d1a; color: var(--text); border: 1px solid var(--accent); border-radius: 4px; padding: 8px; resize: vertical; }
|
||||||
|
button { background: var(--accent); color: var(--text); border: none; padding: 8px 14px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||||
|
.error { color: #e06060; }
|
||||||
|
.success { color: #60e080; }
|
||||||
|
.grid { display: grid; gap: 1rem; }
|
||||||
|
@media (min-width: 800px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Business-QA – Anonymisation et recontextualisation</h1>
|
||||||
|
<p>Exemple d’utilisation du module : anonymiser un payload dossier notaire, puis recontextualiser une réponse IA.</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>1. Payload (question + folder_context)</h2>
|
||||||
|
<textarea id="payload" placeholder='{"question": "Quelles pièces pour M. Dupont ?", "folder_context": {"members": [{"firstName": "Jean", "lastName": "Dupont", "email": "jean@example.com"}]}}'>{"question": "Quelles pièces pour M. Dupont ?", "folder_context": {"members": [{"firstName": "Jean", "lastName": "Dupont", "email": "jean@example.com"}], "type": "vente"}}</textarea>
|
||||||
|
<p><button type="button" id="btnAnonymize">Anonymiser</button></p>
|
||||||
|
<p id="anonStatus" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section>
|
||||||
|
<h2>2. Payload anonymisé</h2>
|
||||||
|
<pre id="anonPayload">—</pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>3. Mapping (placeholder → valeur)</h2>
|
||||||
|
<pre id="mapping">—</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Réponse IA (avec placeholders) à recontextualiser</h2>
|
||||||
|
<textarea id="responseToRestore" placeholder='{"answer": "Pour PII_0 PII_1, prévoir les pièces suivantes…", "nextActionsTable": "", "membersInfoSheet": "", "synthesisRecommendation": ""}'>{"answer": "Pour PII_0 PII_1, prévoir les pièces d'identité et justificatif de domicile.", "nextActionsTable": "- Contacter PII_0 PII_1", "membersInfoSheet": "Membre : PII_0 PII_1 (PII_2)", "synthesisRecommendation": "Dossier standard pour PII_0 PII_1."}</textarea>
|
||||||
|
<p><button type="button" id="btnRecontext">Recontextualiser la réponse</button></p>
|
||||||
|
<p id="recontextStatus" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>5. Réponse recontextualisée</h2>
|
||||||
|
<pre id="recontextResult">—</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const base = window.location.pathname.replace(/\/example\/?.*$/, "");
|
||||||
|
const api = (path) => base + path;
|
||||||
|
|
||||||
|
document.getElementById("btnAnonymize").addEventListener("click", async () => {
|
||||||
|
const payloadEl = document.getElementById("payload");
|
||||||
|
const statusEl = document.getElementById("anonStatus");
|
||||||
|
const anonPayloadEl = document.getElementById("anonPayload");
|
||||||
|
const mappingEl = document.getElementById("mapping");
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payloadEl.value);
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = "JSON invalide.";
|
||||||
|
statusEl.className = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Envoi…";
|
||||||
|
statusEl.className = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(api("/anonymize"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ payload }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
statusEl.textContent = data.message || "Erreur " + res.status;
|
||||||
|
statusEl.className = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
anonPayloadEl.textContent = JSON.stringify(data.anonymizedPayload, null, 2);
|
||||||
|
mappingEl.textContent = JSON.stringify(data.mapping, null, 2);
|
||||||
|
window._lastMapping = data.mapping;
|
||||||
|
statusEl.textContent = "Anonymisation OK.";
|
||||||
|
statusEl.className = "success";
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = "Erreur réseau : " + e.message;
|
||||||
|
statusEl.className = "error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btnRecontext").addEventListener("click", async () => {
|
||||||
|
const mapping = window._lastMapping;
|
||||||
|
const responseEl = document.getElementById("responseToRestore");
|
||||||
|
const statusEl = document.getElementById("recontextStatus");
|
||||||
|
const resultEl = document.getElementById("recontextResult");
|
||||||
|
if (!mapping || !mapping.length) {
|
||||||
|
statusEl.textContent = "Anonymisez d’abord un payload pour obtenir le mapping.";
|
||||||
|
statusEl.className = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(responseEl.value);
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = "JSON réponse invalide.";
|
||||||
|
statusEl.className = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Envoi…";
|
||||||
|
statusEl.className = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(api("/recontextualize"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mapping, response }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
statusEl.textContent = data.message || "Erreur " + res.status;
|
||||||
|
statusEl.className = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultEl.textContent = JSON.stringify(data.response, null, 2);
|
||||||
|
statusEl.textContent = "Recontextualisation OK.";
|
||||||
|
statusEl.className = "success";
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = "Erreur réseau : " + e.message;
|
||||||
|
statusEl.className = "error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
ai_working_help/business-qa/interfaces.md
Normal file
88
ai_working_help/business-qa/interfaces.md
Normal file
@ -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.
|
||||||
37
ai_working_help/business-qa/recontext/recontextualize.js
Normal file
37
ai_working_help/business-qa/recontext/recontextualize.js
Normal file
@ -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 };
|
||||||
@ -29,12 +29,14 @@
|
|||||||
## API (serveur Node)
|
## API (serveur Node)
|
||||||
|
|
||||||
- **POST /v1/enqueue**
|
- **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 }`.
|
Réponse **202** : `{ request_uid }`.
|
||||||
Écrit dans `projects/<id>/data/notary-ai/pending/<safe_uid>.json`. **Authentification** : header `Authorization: Bearer <token>` obligatoire (le token identifie le projet) ; 401 si absent ou inconnu.
|
Écrit dans `projects/<id>/data/notary-ai/pending/<safe_uid>.json`. **Authentification** : header `Authorization: Bearer <token>` obligatoire (le token identifie le projet) ; 401 si absent ou inconnu.
|
||||||
|
|
||||||
- **GET /v1/response/:request_uid**
|
- **GET /v1/response/:request_uid**
|
||||||
Réponse **200** : `{ status: "pending" }` ou `{ status: "responded", response: { answer, nextActionsTable, membersInfoSheet, synthesisRecommendation } }`.
|
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/<id>/data/notary-ai/responded/`, sinon `pending/`. **Authentification** : idem (token obligatoire).
|
Lit d’abord `projects/<id>/data/notary-ai/responded/`, sinon `pending/`. **Authentification** : idem (token obligatoire).
|
||||||
|
|
||||||
- **GET /v1/health** et **GET /health** : santé du service (sans authentification).
|
- **GET /v1/health** et **GET /health** : santé du service (sans authentification).
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ai_working_help API server.
|
* ai_working_help API server.
|
||||||
* Routes: POST /v1/enqueue, GET /v1/response/:request_uid, GET /health, GET /v1/health.
|
* 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
|
* Project id and env are resolved from the Bearer token by searching all
|
||||||
* projects/<id>/.secrets/<env>/ia_token files; the matching project id is used for the spooler.
|
* projects/<id>/.secrets/<env>/ia_token files; the matching project id is used for the spooler.
|
||||||
* Spooler: projects/<id>/data/notary-ai/{pending,responded}.
|
* Spooler: projects/<id>/data/notary-ai/{pending,responded}.
|
||||||
@ -9,9 +10,11 @@ const express = require("express");
|
|||||||
const bodyParser = require("body-parser");
|
const bodyParser = require("body-parser");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const businessQa = require("./business-qa/api");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(bodyParser.json({ limit: "1mb" }));
|
app.use(bodyParser.json({ limit: "1mb" }));
|
||||||
|
app.use("/v1/business-qa", businessQa.router);
|
||||||
|
|
||||||
const PORT = process.env.AI_WORKING_HELP_PORT || 3020;
|
const PORT = process.env.AI_WORKING_HELP_PORT || 3020;
|
||||||
const IA_DEV_ROOT = path.resolve(__dirname, "..");
|
const IA_DEV_ROOT = path.resolve(__dirname, "..");
|
||||||
@ -114,7 +117,7 @@ app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
|
|||||||
if (!requestUid || typeof requestUid !== "string") {
|
if (!requestUid || typeof requestUid !== "string") {
|
||||||
return res.status(400).json({ message: "Missing request_uid" });
|
return res.status(400).json({ message: "Missing request_uid" });
|
||||||
}
|
}
|
||||||
const payload = {
|
let payload = {
|
||||||
request_uid: requestUid,
|
request_uid: requestUid,
|
||||||
folder_uid: body.folder_uid,
|
folder_uid: body.folder_uid,
|
||||||
office_uid: body.office_uid,
|
office_uid: body.office_uid,
|
||||||
@ -123,6 +126,15 @@ app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
|
|||||||
folder_context: body.folder_context || {},
|
folder_context: body.folder_context || {},
|
||||||
status: "pending",
|
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 {
|
try {
|
||||||
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
|
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
|
||||||
if (!fs.existsSync(respondedDir)) fs.mkdirSync(respondedDir, { 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 respondedDir = path.join(dir, "responded");
|
||||||
const foundResponded = findFileByRequestUid(respondedDir, requestUid);
|
const foundResponded = findFileByRequestUid(respondedDir, requestUid);
|
||||||
if (foundResponded && foundResponded.data.response) {
|
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({
|
return res.status(200).json({
|
||||||
status: "responded",
|
status: "responded",
|
||||||
response: foundResponded.data.response,
|
response,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const pendingDir = path.join(dir, "pending");
|
const pendingDir = path.join(dir, "pending");
|
||||||
|
|||||||
@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
|
GITEA_ISSUES_DIR="${GITEA_ISSUES_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
|
||||||
REPO_ROOT="${GITEA_ISSUES_DIR}/.."
|
REPO_ROOT="${GITEA_ISSUES_DIR}/.."
|
||||||
DOCS_DIR="${REPO_ROOT}/docs"
|
DOCS_DIR="${DOCS_DIR:-${REPO_ROOT}/docs}"
|
||||||
# shellcheck source=lib.sh
|
# shellcheck source=lib.sh
|
||||||
source "${GITEA_ISSUES_DIR}/lib.sh"
|
source "${GITEA_ISSUES_DIR}/lib.sh"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user