Amélioration

This commit is contained in:
ocroguennec committed 2026-09-27 11:38:45 +02:00
1 parent add66a9310
commit e0145c8727
46 files changed
+305

No files matched your search

+62
View File
@@ -161,3 +161,65 @@ export async function testConnection({ apiKey, model }) {
.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: message.usage };
}
/**
* lookupInstitution() — recherche web automatique (outil natif Anthropic
* "web_search", exécuté côté serveur Anthropic) pour pré-remplir une fiche
* institution depuis l'admin (icône "baguette magique", InstitutionModal —
* demande Olivier 27/09/26) : au minimum une description succincte et le
* pays de domiciliation (code ISO 3166-1 alpha-2, cf. CountrySelect.jsx
* côté frontend).
*
* Contrairement à testConnection() (ping minimal, sans outil), cet appel
* n'est pas en streaming : pour un outil serveur, Anthropic exécute la/les
* recherche(s) et le tour de lecture des résultats en interne, la réponse
* finale (texte, éventuellement précédée de blocs server_tool_use /
* web_search_tool_result) arrivant en un seul message — donc pas de boucle
* côté client comme dans streamChat(), mais un appel qui peut prendre
* plusieurs secondes.
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model
* @param {string} opts.nom Nom de l'institution à rechercher
* @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 }) {
const anthropic = new Anthropic({ apiKey });
const started = Date.now();
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.`,
`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');
const message = await anthropic.messages.create({
model,
max_tokens: 1024,
tools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 4 }],
messages: [{ role: 'user', content: prompt }],
});
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 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.");
return { description, domiciliation, durationMs: Date.now() - started, sourcesCount };
}
@@ -7,6 +7,10 @@ import { fileURLToPath } from 'url';
import sharp from 'sharp';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
import { decryptSecret } from '../ai/crypto.js';
import { lookupInstitution } from '../ai/providers/anthropic.js';
import { audit } from '../utils/audit.js';
/**
* routes/institutionsReferentiel.js — Référentiel admin des institutions financières
@@ -281,4 +285,68 @@ router.delete('/:id/icone', (req, res, next) => {
} catch (e) { next(e); }
});
// ── POST /api/institutions-referentiel/ai-lookup ────────────────────────────
// Icône "baguette magique" d'InstitutionModal (demande Olivier 27/09/26) : recherche
// web automatique via le connecteur IA (Admin > Intelligence artificielle), pour
// pré-remplir au minimum description + domiciliation depuis le nom (et l'URL si
// déjà saisie) tapés dans le formulaire — fonctionne aussi bien en création (avant
// tout enregistrement) qu'en édition. Réponse toujours 200 : { ok:false, error }
// n'est pas une erreur HTTP mais un échec applicatif (IA désactivée, aucun
// fournisseur actif, type de fournisseur non pris en charge...) — même précédent
// que POST /providers/:id/test dans adminAi.js.
const AiLookupSchema = z.object({
nom: z.string().min(1).max(200),
url: z.string().url().optional().or(z.literal('')).nullable(),
});
router.post('/ai-lookup', async (req, res, next) => {
try {
const { nom, url } = AiLookupSchema.parse(req.body);
const config = db.prepare('SELECT actif FROM ai_config WHERE id = 1').get();
if (!config?.actif) {
return res.json({ ok: false, error: "L'Assistant IA n'est pas activé (Admin > Intelligence artificielle)." });
}
const provider = getDefaultActiveProvider();
if (!provider) {
return res.json({ ok: false, error: "Aucun fournisseur IA actif n'est configuré." });
}
// Seul le fournisseur "anthropic" (natif) expose l'outil de recherche web
// serveur utilisé par lookupInstitution() — un fournisseur openai_compatible
// n'a aucune garantie d'accès web réel selon le modèle/service derrière
// base_url, on préfère donc refuser explicitement plutôt que de renvoyer une
// réponse non vérifiée présentée comme une recherche internet.
if (provider.type !== 'anthropic') {
return res.json({
ok: false,
error: `La recherche web automatique n'est prise en charge que pour un fournisseur Anthropic (natif) — le fournisseur actif ("${provider.nom}") est de type "${provider.type}".`,
});
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`);
}
try {
const result = await lookupInstitution({ apiKey, model: provider.modele, nom, url: url || undefined });
audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id,
details: { nom, ok: true, dureeMs: result.durationMs, sourcesCount: result.sourcesCount },
});
res.json({ ok: true, description: result.description, domiciliation: result.domiciliation, dureeMs: result.durationMs });
} catch (e) {
audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id,
details: { nom, ok: false, error: e.message },
});
res.json({ ok: false, error: e.message });
}
} catch (e) { next(e); }
});
export default router;