Reciprok Docs
Intelligence

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

  1. 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.
  2. Toutes les actions IA sont logguées dans la timeline avec actor: "ai".
  3. Confirmation utilisateur obligatoire pour les actions à effet de bord visibles (envoi d'email, sélection d'un gagnant, suppression).
  4. L'IA voit la KB structurée, pas les transcripts bruts (sauf si on lui demande explicitement).
  5. Pas de "trust the AI", chaque suggestion est explicable, sourçable, corrigeable.

Stack IA

ComposantChoix
LLMClaude 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 audioWhisper (OpenAI API) ou Deepgram
Vector storepgvector (extension Postgres)
Tool callingAnthropic 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 :

  1. Claude propose un tool call (ex: send_email)
  2. Le serveur n'exécute pas immédiatement, il enregistre la proposition dans le message
  3. Le frontend affiche un composant AIActionCard avec Approve/Reject
  4. L'utilisateur clique :
    • Approve → exécution + tool result envoyé à Claude → Claude continue
    • Reject → tool result = { rejected: true, reason? } → Claude s'adapte
  5. La timeline log : ai_suggestion, puis email_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 :

  1. SSE depuis Elysia : Anthropic.messages.stream(...)
  2. Le serveur push chaque chunk au client
  3. Côté frontend, accumulation dans le state jusqu'au message_stop
  4. Quand le stream finit, persistance complète du message

Audio entrant (vocal de l'utilisateur)

Flow :

  1. Frontend enregistre l'audio (MediaRecorder)
  2. Upload vers le backend
  3. Whisper transcrit
  4. Texte injecté comme user message dans la conversation
  5. Boucle Claude normale

Génération audio sortante (TTS)

Pas dans le scope MVP. Quand on l'ajoute :

  1. Provider : OpenAI TTS, ElevenLabs, ou Cartesia
  2. Bouton TTS sur chaque message assistant (déjà prévu côté UI : AITTSButton)
  3. 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 :

  1. aiConversation est trouvée par requestId
  2. aiMessages chargés dans l'ordre
  3. Le contexte (demande, résultats) est rebuilt
  4. 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

On this page