From 1e3994f17f5eddff510a8b384354ea58d3e5fe8a Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 27 Sep 2026 11:54:18 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/ai/providers/anthropic.js | 55 ++++++++++++++++--- backend/src/routes/institutionsReferentiel.js | 47 +++++++++++++++- frontend/src/pages/AdminInstitutions.jsx | 8 ++- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/backend/src/ai/providers/anthropic.js b/backend/src/ai/providers/anthropic.js index 0ca0903..3fa56bc 100644 --- a/backend/src/ai/providers/anthropic.js +++ b/backend/src/ai/providers/anthropic.js @@ -185,16 +185,26 @@ export async function testConnection({ apiKey, model }) { * @param {string} [opts.url] URL du site officiel, si déjà connue (aide la recherche) * @returns {Promise<{description:string, domiciliation:string|null, durationMs:number, sourcesCount:number}>} */ -export async function lookupInstitution({ apiKey, model, nom, url }) { +export async function lookupInstitution({ apiKey, model, nom, url, categories }) { const anthropic = new Anthropic({ apiKey }); const started = Date.now(); + // `categories` : liste fermée [{id, nom}] issue de categories_institutions (fournie par + // l'appelant, cf. institutionsReferentiel.js) — on demande au modèle de choisir EXACTEMENT + // parmi ces libellés plutôt que d'en inventer, pour pouvoir les remapper en id de façon fiable + // (retrofit du 28/09/26, demande Olivier : enrichir aussi URL/domiciliation/catégories, pas + // seulement la description). + const categoryNames = (categories || []).map((c) => c.nom); + const prompt = [ `Recherche sur le web des informations fiables et à jour sur l'institution financière suivante : "${nom}"${url ? ` (site officiel présumé : ${url})` : ''}.`, `C'est potentiellement une banque, néobanque, courtier, assureur, société de gestion ou établissement de paiement.`, + categoryNames.length + ? `Catégories possibles (choisis zéro, une ou plusieurs valeurs EXACTEMENT parmi cette liste, sans en inventer d'autres) : ${categoryNames.join(', ')}.` + : '', `Réponds UNIQUEMENT avec un objet JSON valide, sans aucun texte autour, au format exact :`, - `{"description": "", "domiciliation": ""}`, - ].join('\n'); + `{"description": "", "domiciliation": "", "url": "", "categories": [<0 à N valeurs issues STRICTEMENT de la liste de catégories ci-dessus>]}`, + ].filter(Boolean).join('\n'); const message = await anthropic.messages.create({ model, @@ -203,23 +213,54 @@ export async function lookupInstitution({ apiKey, model, nom, url }) { messages: [{ role: 'user', content: prompt }], }); + // Capturé avant le parsing JSON (et rattaché à toute erreur levée plus bas via + // err.usage/err.toolCalled) afin que l'appelant puisse journaliser dans + // ai_usage_log — au même titre que le chat utilisateur (routes/ai.js) — même + // quand la réponse du modèle n'est pas exploitable : l'appel API a bien + // consommé des tokens (et potentiellement déclenché des recherches web). + const usage = { input_tokens: message.usage?.input_tokens ?? null, output_tokens: message.usage?.output_tokens ?? null }; + const toolCalled = (message.content || []).some((b) => b.type === 'server_tool_use' || b.type === 'web_search_tool_result'); + const sourcesCount = (message.content || []).filter((b) => b.type === 'web_search_tool_result').length; + const textBlocks = (message.content || []).filter((b) => b.type === 'text').map((b) => b.text); const fullText = textBlocks.join('\n').trim(); - const sourcesCount = (message.content || []).filter((b) => b.type === 'web_search_tool_result').length; let parsed; try { const jsonMatch = fullText.match(/\{[\s\S]*\}/); parsed = JSON.parse(jsonMatch ? jsonMatch[0] : fullText); } catch (e) { - throw new Error(`Réponse du modèle non exploitable (JSON attendu) : ${fullText.slice(0, 200) || '(vide)'}`); + const err = new Error(`Réponse du modèle non exploitable (JSON attendu) : ${fullText.slice(0, 200) || '(vide)'}`); + err.usage = usage; err.toolCalled = toolCalled; err.sourcesCount = sourcesCount; + throw err; } const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; let domiciliation = typeof parsed.domiciliation === 'string' ? parsed.domiciliation.trim().toUpperCase() : null; if (domiciliation && !/^[A-Z]{2}$/.test(domiciliation)) domiciliation = null; - if (!description) throw new Error("Le modèle n'a renvoyé aucune description exploitable."); + let siteUrl = typeof parsed.url === 'string' ? parsed.url.trim() : ''; + if (!/^https?:\/\//i.test(siteUrl)) siteUrl = ''; - return { description, domiciliation, durationMs: Date.now() - started, sourcesCount }; + // Rematch strict par nom (insensible à la casse) contre la liste fournie — un libellé + // halluciné par le modèle (hors liste, faute de frappe...) est simplement ignoré plutôt que + // de faire planter la requête ou de créer un id inexistant. + const rawCategories = Array.isArray(parsed.categories) ? parsed.categories : []; + const categoryIds = []; + for (const raw of rawCategories) { + if (typeof raw !== 'string') continue; + const match = (categories || []).find((c) => c.nom.toLowerCase() === raw.trim().toLowerCase()); + if (match && !categoryIds.includes(match.id)) categoryIds.push(match.id); + } + + if (!description) { + const err = new Error("Le modèle n'a renvoyé aucune description exploitable."); + err.usage = usage; err.toolCalled = toolCalled; err.sourcesCount = sourcesCount; + throw err; + } + + return { + description, domiciliation, url: siteUrl || null, categoryIds, + durationMs: Date.now() - started, sourcesCount, toolCalled, usage, + }; } diff --git a/backend/src/routes/institutionsReferentiel.js b/backend/src/routes/institutionsReferentiel.js index 9f54da1..965eafe 100644 --- a/backend/src/routes/institutionsReferentiel.js +++ b/backend/src/routes/institutionsReferentiel.js @@ -332,14 +332,55 @@ router.post('/ai-lookup', async (req, res, next) => { throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`); } + // Journalisation dans ai_usage_log (même table que le chat utilisateur, routes/ai.js) — + // demandé par Olivier le 27/09/26 : ces appels consomment de vrais tokens (recherche web + // incluse) et doivent remonter dans Admin > Assistant IA > Usage, pas seulement dans le + // journal d'audit. investisseur_id = null (route admin, pas de contexte investisseur) — + // best-effort comme dans routes/ai.js : un échec de log ne doit jamais faire échouer la + // réponse à l'utilisateur. + function logUsage({ usage, toolCalled, erreur }) { + try { + db.prepare(` + INSERT INTO ai_usage_log + (user_id, investisseur_id, provider_id, modele, tokens_entree, tokens_sortie, outils_appeles, erreur) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + req.user.id, + null, + provider.id, + provider.modele, + usage?.input_tokens ?? null, + usage?.output_tokens ?? null, + toolCalled ? JSON.stringify(['web_search']) : null, + erreur || null, + ); + } catch (logErr) { + console.error("[institutions-referentiel/ai-lookup] échec du log d'usage (ignoré) :", logErr.message); + } + } + + // Liste fermée des catégories existantes — transmise au modèle pour qu'il choisisse + // EXACTEMENT parmi des libellés déjà connus du référentiel (voir lookupInstitution()), + // remappables en id de façon fiable plutôt que du texte libre à recréer/dédupliquer. + const categories = db.prepare('SELECT id, nom FROM categories_institutions ORDER BY nom').all(); + try { - const result = await lookupInstitution({ apiKey, model: provider.modele, nom, url: url || undefined }); + const result = await lookupInstitution({ apiKey, model: provider.modele, nom, url: url || undefined, categories }); + logUsage({ usage: result.usage, toolCalled: result.toolCalled, erreur: null }); audit(req, { action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id, - details: { nom, ok: true, dureeMs: result.durationMs, sourcesCount: result.sourcesCount }, + details: { nom, ok: true, dureeMs: result.durationMs, sourcesCount: result.sourcesCount, categoriesCount: result.categoryIds.length }, + }); + res.json({ + ok: true, + description: result.description, + domiciliation: result.domiciliation, + url: result.url, + categories_ids: result.categoryIds, + dureeMs: result.durationMs, }); - res.json({ ok: true, description: result.description, domiciliation: result.domiciliation, dureeMs: result.durationMs }); } catch (e) { + logUsage({ usage: e.usage, toolCalled: e.toolCalled, erreur: e.message }); audit(req, { action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id, details: { nom, ok: false, error: e.message }, diff --git a/frontend/src/pages/AdminInstitutions.jsx b/frontend/src/pages/AdminInstitutions.jsx index b14948e..4d7b521 100644 --- a/frontend/src/pages/AdminInstitutions.jsx +++ b/frontend/src/pages/AdminInstitutions.jsx @@ -96,8 +96,12 @@ function InstitutionModal({ row, onClose, onSaved }) { ...f, description: r.description || f.description, domiciliation: r.domiciliation || f.domiciliation, + url: r.url || f.url, + categories_ids: (r.categories_ids && r.categories_ids.length) + ? Array.from(new Set([...(f.categories_ids || []), ...r.categories_ids])) + : (f.categories_ids || []), })); - setAiMsg({ ok: true, text: "Description et domiciliation renseignées automatiquement — vérifiez avant d'enregistrer." }); + setAiMsg({ ok: true, text: "Champs complétés automatiquement (description, URL, domiciliation, catégories) — vérifiez avant d'enregistrer." }); } else { setAiMsg({ ok: false, text: r.error || 'Recherche IA indisponible.' }); } @@ -159,7 +163,7 @@ function InstitutionModal({ row, onClose, onSaved }) { border: '1px solid var(--border)', borderRadius: 6, cursor: (aiLoading || !form.nom.trim()) ? 'not-allowed' : 'pointer', background: 'var(--surface-2)', color: 'var(--text)', opacity: (aiLoading || !form.nom.trim()) ? 0.6 : 1 }} - title="Rechercher automatiquement sur le web (description, domiciliation) via l'Assistant IA"> + title="Rechercher automatiquement sur le web (description, URL, domiciliation, catégories) via l'Assistant IA"> {aiLoading ? 'Recherche en cours…' : "Compléter avec l'IA"} {aiMsg && (