Amélioration
This commit is contained in:
1 parent
e0145c8727
commit
1e3994f17f
3 files changed
+98
-12
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
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, {
|
||||
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 },
|
||||
|
||||
@@ -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">
|
||||
<IconWand /> {aiLoading ? 'Recherche en cours…' : "Compléter avec l'IA"}
|
||||
</button>
|
||||
{aiMsg && (
|
||||
|
||||
Reference in new issue
Block a user