Amélioration

This commit is contained in:
ocroguennec committed 2026-09-27 11:54:18 +02:00
1 parent e0145c8727
commit 1e3994f17f
3 files changed
+98 -12

No files matched your search

+48 -7
View File
@@ -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) * @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}>} * @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 anthropic = new Anthropic({ apiKey });
const started = Date.now(); 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 = [ const prompt = [
`Recherche sur le web des informations fiables et à jour sur l'institution financière suivante : "${nom}"${url ? ` (site officiel présumé : ${url})` : ''}.`, `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.`, `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 :`, `Réponds UNIQUEMENT avec un objet JSON valide, sans aucun texte autour, au format exact :`,
`{"description": "<description succincte en français, 1 à 3 phrases : activité, statut/régulateur si pertinent>", "domiciliation": "<code pays ISO 3166-1 alpha-2 du siège social, ex. FR, LU, DE — null si inconnu>"}`, `{"description": "<description succincte en français, 1 à 3 phrases : activité, statut/régulateur si pertinent>", "domiciliation": "<code pays ISO 3166-1 alpha-2 du siège social, ex. FR, LU, DE — null si inconnu>", "url": "<URL du site officiel, https://... — null si non trouvée>", "categories": [<0 à N valeurs issues STRICTEMENT de la liste de catégories ci-dessus>]}`,
].join('\n'); ].filter(Boolean).join('\n');
const message = await anthropic.messages.create({ const message = await anthropic.messages.create({
model, model,
@@ -203,23 +213,54 @@ export async function lookupInstitution({ apiKey, model, nom, url }) {
messages: [{ role: 'user', content: prompt }], 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 textBlocks = (message.content || []).filter((b) => b.type === 'text').map((b) => b.text);
const fullText = textBlocks.join('\n').trim(); const fullText = textBlocks.join('\n').trim();
const sourcesCount = (message.content || []).filter((b) => b.type === 'web_search_tool_result').length;
let parsed; let parsed;
try { try {
const jsonMatch = fullText.match(/\{[\s\S]*\}/); const jsonMatch = fullText.match(/\{[\s\S]*\}/);
parsed = JSON.parse(jsonMatch ? jsonMatch[0] : fullText); parsed = JSON.parse(jsonMatch ? jsonMatch[0] : fullText);
} catch (e) { } 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() : ''; const description = typeof parsed.description === 'string' ? parsed.description.trim() : '';
let domiciliation = typeof parsed.domiciliation === 'string' ? parsed.domiciliation.trim().toUpperCase() : null; let domiciliation = typeof parsed.domiciliation === 'string' ? parsed.domiciliation.trim().toUpperCase() : null;
if (domiciliation && !/^[A-Z]{2}$/.test(domiciliation)) domiciliation = 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,
};
} }
+44 -3
View File
@@ -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}`); 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 { try {
const result = await lookupInstitution({ apiKey, model: provider.modele, nom, url: url || undefined }); 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, categories });
logUsage({ usage: result.usage, toolCalled: result.toolCalled, erreur: null });
audit(req, { audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id, 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) { } catch (e) {
logUsage({ usage: e.usage, toolCalled: e.toolCalled, erreur: e.message });
audit(req, { audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id, action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id,
details: { nom, ok: false, error: e.message }, details: { nom, ok: false, error: e.message },
+6 -2
View File
@@ -96,8 +96,12 @@ function InstitutionModal({ row, onClose, onSaved }) {
...f, ...f,
description: r.description || f.description, description: r.description || f.description,
domiciliation: r.domiciliation || f.domiciliation, 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 { } else {
setAiMsg({ ok: false, text: r.error || 'Recherche IA indisponible.' }); 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, border: '1px solid var(--border)', borderRadius: 6,
cursor: (aiLoading || !form.nom.trim()) ? 'not-allowed' : 'pointer', cursor: (aiLoading || !form.nom.trim()) ? 'not-allowed' : 'pointer',
background: 'var(--surface-2)', color: 'var(--text)', opacity: (aiLoading || !form.nom.trim()) ? 0.6 : 1 }} 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">
<IconWand /> {aiLoading ? 'Recherche en cours…' : "Compléter avec l'IA"} <IconWand /> {aiLoading ? 'Recherche en cours…' : "Compléter avec l'IA"}
</button> </button>
{aiMsg && ( {aiMsg && (