Amélioration
This commit is contained in:
1 parent
add66a9310
commit
e0145c8727
46 files changed
+305
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user