Architecture IA
Comment Claude est intégré, chat, tools, knowledge base, training
Le pari central de Reciprok : l'IA n'est pas un chatbot collé sur le côté, c'est un agent qui agit dans le système, avec les mêmes droits et la même traçabilité qu'un utilisateur humain.
Principes
- L'IA appelle l'API comme un client, pas de logique métier dupliquée. Si une action peut être faite par l'IA, elle peut être faite par un humain via la même route.
- Toutes les actions IA sont logguées dans la timeline avec
actor: "ai". - Confirmation utilisateur obligatoire pour les actions à effet de bord visibles (envoi d'email, sélection d'un gagnant, suppression).
- L'IA voit la KB structurée, pas les transcripts bruts (sauf si on lui demande explicitement).
- Pas de "trust the AI", chaque suggestion est explicable, sourçable, corrigeable.
Stack IA
| Composant | Choix |
|---|---|
| LLM | Claude API (Anthropic SDK), Sonnet 4.6 par défaut, Opus pour les tâches complexes |
| Embeddings | À choisir : OpenAI text-embedding-3-small (1536 dims) ou Voyage voyage-3-large |
| Transcription audio | Whisper (OpenAI API) ou Deepgram |
| Vector store | pgvector (extension Postgres) |
| Tool calling | Anthropic native tool use |
Architecture en 3 couches
Tools exposés à Claude
Format Anthropic :
const tools = [
{
name: "search_members",
description: "Search for members matching criteria. Use this to find candidates for a request.",
input_schema: {
type: "object",
properties: {
postalCodes: { type: "array", items: { type: "string" } },
minCapacity: { type: "number" },
maxBudget: { type: "number" },
configurations: {
type: "array",
items: {
type: "string",
enum: ["meeting", "u_shape", "theater", "cabaret", "classroom", "banquet", "cocktail"],
},
description: "Configurations Kactus acceptées (plusieurs possibles)",
},
dates: { type: "array", items: { type: "string", format: "date" } },
semanticQuery: { type: "string", description: "Free text describing the desired ambiance, style, or specific needs" },
},
},
},
{
name: "add_member_to_results",
description: "Add a member to the request's result list",
input_schema: { /* requestId, memberId, stage, position? */ },
},
{
name: "remove_member_from_results",
input_schema: { /* requestId, memberId */ },
},
{
name: "advance_member_in_pipeline",
input_schema: { /* requestId, memberId, toStage */ },
},
{
name: "update_request_field",
description: "Update a field of the current request (budget, dates, formats, etc.)",
input_schema: { /* requestId, field, value */ },
},
{
name: "get_member_knowledge",
description: "Retrieve the knowledge base entries for a specific member",
input_schema: { /* memberId, types?, query? */ },
},
{
name: "find_similar_requests",
description: "Find past requests similar to the current one. Use to learn from history.",
input_schema: { /* requestId, limit? */ },
},
{
name: "draft_email",
description: "Draft an email (qualification, catalog, recommendation, etc.). Returns text, does NOT send.",
input_schema: { /* requestId, type, recipient, context */ },
},
{
name: "send_email",
description: "Send an email. REQUIRES user confirmation before execution.",
input_schema: { /* threadId?, to, subject, body, withWhatsapp? */ },
requires_confirmation: true,
},
{
name: "compose_catalog",
description: "Compose a catalog from current results, 4 recommended + 4 compatible",
input_schema: { /* requestId, recommended[], compatible[] */ },
},
{
name: "list_request_results",
description: "List the current member candidates for the request",
input_schema: { /* requestId */ },
},
];Confirmation utilisateur
Certains tools sont marqués requires_confirmation: true. Le flow :
- Claude propose un tool call (ex:
send_email) - Le serveur n'exécute pas immédiatement, il enregistre la proposition dans le message
- Le frontend affiche un composant
AIActionCardavec Approve/Reject - L'utilisateur clique :
- Approve → exécution + tool result envoyé à Claude → Claude continue
- Reject → tool result =
{ rejected: true, reason? }→ Claude s'adapte
- La timeline log :
ai_suggestion, puisemail_sent(ou rejet)
Boucle d'orchestration
Pseudo-code de la boucle :
async function chat(requestId: string, userId: string, userMessage: string) {
const conversation = await getOrCreateConversation(requestId, userId);
await appendMessage(conversation.id, "user", userMessage);
const context = await buildContext(requestId);
const systemPrompt = buildSystemPrompt(context);
const messages = await loadMessages(conversation.id);
let response = await claude.messages.create({
model: "claude-sonnet-4-5",
system: systemPrompt,
messages,
tools,
});
while (response.stop_reason === "tool_use") {
const toolUses = response.content.filter((c) => c.type === "tool_use");
const toolResults = [];
for (const toolUse of toolUses) {
if (TOOLS_REQUIRING_CONFIRMATION.includes(toolUse.name)) {
// Persiste la proposition, attend l'utilisateur
await appendMessage(conversation.id, "assistant", { content: response.content });
return { needsConfirmation: true, toolUseId: toolUse.id };
}
const result = await executeTool(toolUse, { userId, requestId });
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result });
}
await appendMessage(conversation.id, "assistant", { content: response.content });
await appendMessage(conversation.id, "tool", { content: toolResults });
response = await claude.messages.create({
model: "claude-sonnet-4-5",
system: systemPrompt,
messages: await loadMessages(conversation.id),
tools,
});
}
await appendMessage(conversation.id, "assistant", { content: response.content });
return { content: response.content };
}System prompt
Le prompt système est construit dynamiquement avec :
- Rôle : "Tu es l'assistant IA de Reciprok, plateforme de mise en relation événementielle."
- Acteurs : qui est l'utilisateur, qui sont les membres, qui sont les organisateurs
- Convention de référence : "Tu utilises systématiquement le code unique des membres (ex: #102) pour les désigner."
- Outils disponibles : description haut niveau de ce que tu peux faire
- Demande courante : récap structuré
- Résultats actuels : liste des membres déjà candidats
- Contraintes : "Avant toute action à effet visible (envoi email, sélection gagnant), demande confirmation."
- Style : "Réponses concises. Cite tes sources. Justifie tes recommandations en une phrase."
Streaming
Pour l'UX du chat, on stream les tokens. Implémentation :
- SSE depuis Elysia :
Anthropic.messages.stream(...) - Le serveur push chaque chunk au client
- Côté frontend, accumulation dans le state jusqu'au
message_stop - Quand le stream finit, persistance complète du message
Audio entrant (vocal de l'utilisateur)
Flow :
- Frontend enregistre l'audio (MediaRecorder)
- Upload vers le backend
- Whisper transcrit
- Texte injecté comme
user messagedans la conversation - Boucle Claude normale
Génération audio sortante (TTS)
Pas dans le scope MVP. Quand on l'ajoute :
- Provider : OpenAI TTS, ElevenLabs, ou Cartesia
- Bouton TTS sur chaque message assistant (déjà prévu côté UI :
AITTSButton) - Cache des audios générés (S3) pour ne pas re-générer à chaque écoute
Persistance et reprise
L'historique du chat est lié à la demande. Si l'utilisateur quitte et revient :
aiConversationest trouvée parrequestIdaiMessageschargés dans l'ordre- Le contexte (demande, résultats) est rebuilt
- La conversation reprend sans rien perdre
Pas de "session expiration", la conversation vit aussi longtemps que la demande.
Coûts et budget
À surveiller :
- Tokens d'input : on injecte le contexte de la demande à chaque message → optimiser via prompt caching (Anthropic supporte le caching)
- Tokens d'output : streaming permet d'arrêter tôt si l'utilisateur interrompt
- Embeddings : générés une fois par membre, re-générés quand la fiche change significativement
- Whisper : payé à la minute d'audio
Implémenter un compteur par utilisateur dès le début pour suivre les coûts.
Garde-fous
- Rate limit par utilisateur (ex: 60 messages/heure)
- Timeout sur les tools (5s max par tool)
- Max iterations dans la boucle (ex: 10 tool uses max avant de stopper et demander à l'humain)
- Sandboxing des inputs : ne jamais injecter du texte utilisateur dans le system prompt sans escaping