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)
* @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": "<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>"}`,
].join('\n');
`{"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>]}`,
].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,
};
}
+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}`);
}
// 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 },