Aller au contenu

Tâches

Lancer un prompt du module IA en arrière-plan, sur un événement avec un abonné durable ou à heure fixe avec une tâche planifiée, et ranger la réponse à côté du contenu.

Un prompt n'a pas besoin d'un bouton. Le projet le lance quand une soumission arrive, quand une image est téléversée, ou chaque matin, avec les outils du core : les événements et les tâches planifiées. Le module ne fournit pas de file à lui : celle du core relance déjà ce qui échoue.

Les exemples de cette page sont des exemples : les noms site.*, forms.submission et les fonctions qu'ils appellent sont ceux d'un projet imaginaire. Les API du core et du module, elles, sont exactes.

Sur un événement

Un appel au fournisseur prend des secondes, et peut échouer. Il se fait donc toujours dans un abonné durable : appelé après l'enregistrement de l'opération, hors de sa transaction, et relancé par la file du core s'il échoue. Un abonné immédiat bloquerait la soumission du visiteur le temps de la réponse du modèle.

// events.js, exemple
import { runPrompt, saveResult } from '@fraza/module-ai/server';
import { getSubmissionById } from '@fraza/module-forms/server';

export default function events({ on }) {
  on('forms.submission.created', async (event) => {
    const submission = await getSubmissionById(event.payload.submissionId);
    if (!submission) return; // supprimée depuis

    const result = await runPrompt('site.summary', { context: { message: submission.data.message } });
    if (!result.success) {
      // Fournisseur en panne ou saturé : la file réessaie plus tard.
      if (result.error.retryable) throw new Error(result.error.message);
      return; // toute autre erreur échouerait de la même façon à chaque essai
    }

    await saveResult({
      target: 'forms.submission',
      id: submission.id,
      prompt: 'site.summary',
      data: result.data,
      model: result.model,
    });
  }, { durable: true });
}

Le prompt site.summary est celui de Prompts, déclaré dans ai.js.

Lever sur retryable, et seulement sur lui. runPrompt ne lève pas : il rend { success: false, error }. error.retryable vaut true quand un essai plus tard peut réussir : délai dépassé, fournisseur injoignable, limite du fournisseur, panne de son côté, réponse hors schéma. Lever dans ce cas rend l'échec à la file, qui relance avec un délai qui double. Une clé refusée, un modèle inconnu, une demande que le modèle refuse échoueraient pareil à chacun des douze essais : l'abonné s'arrête. Voir Codes d'erreur.

Ranger la réponse, ne pas l'appliquer. saveResult garde la réponse à côté du contenu, et l'écran de ce contenu la montre avec AiResults jusqu'à ce que quelqu'un l'utilise ou l'ignore. Une seule réponse est gardée par contenu et par prompt : une livraison rejouée, ou une tâche relancée, remplace la précédente au lieu d'en ajouter une.

L'écran doit placer AiResults pour la montrer. Seul Médias le fait parmi les modules officiels : dans cet exemple, la réponse s'affiche sur un écran du projet, ou se relit par getResults('forms.submission', id).

Effacer avec le contenu. Quand le contenu est supprimé, deleteResults(target, id) retire ses réponses. Médias le fait sur medias.media.deleted.

Vérifier que l'IA est prête. isAiConfigured() dit si un appel peut partir. Un abonné qui s'en sert évite un échec not_configured par événement tant que le fournisseur n'est pas configuré.

À heure fixe

Une tâche planifiée appelle un prompt comme n'importe quel code du serveur. Elle peut se déclarer dans ai.js, chargé au démarrage par instrumentation.js.

// ai.js, exemple : chaque matin, un résumé des demandes de la veille par courriel
import { schedule } from '@fraza/core/cron';
import { queueEmail } from '@fraza/core/email/outbox';
import { definePrompt, runPrompt } from '@fraza/module-ai/server';
import { listYesterdayRequests } from './lib/requests.server.js';

definePrompt({
  name: 'site.digest',
  system: 'Tu prépares le point du matin d\'une clinique vétérinaire.',
  instructions: 'Résume les demandes en quelques lignes, les urgences d\'abord.',
  output: {
    type: 'object',
    properties: {
      subject: { type: 'string', maxLength: 120 },
      text: { type: 'string', maxLength: 4000 },
    },
    required: ['subject', 'text'],
  },
  build: ({ context, instructions }) => `${instructions}\n\n${context.requests.join('\n---\n')}`,
});

await schedule('site-digest', '0 7 * * 1-5', async ({ signal }) => {
  const requests = await listYesterdayRequests();
  if (requests.length === 0) return { sent: false };

  const result = await runPrompt('site.digest', { context: { requests }, signal });
  if (!result.success) throw new Error(result.error.message);

  const day = new Date().toISOString().slice(0, 10);
  await queueEmail(
    { to: '[email protected]', subject: result.data.subject, text: result.data.text },
    { dedupeKey: `site-digest:${day}` },
  );
  return { sent: true, tokens: result.usage.outputTokens };
}, { lock: { ttlSeconds: 900 } });

Le verrou. Sur un site à plusieurs instances, chacune planifie la tâche : lock fait qu'une seule l'exécute à chaque déclenchement, et qu'un seul courriel part.

Le signal. signal passé à runPrompt arrête l'attente du fournisseur quand la tâche dépasse son délai, une heure par défaut.

La file d'envoi. queueEmail reprend l'envoi si le fournisseur de courriel échoue, et dedupeKey empêche un second courriel le même jour si la tâche est relancée à la main. Voir File d'envoi.

Une tâche qui lève est enregistrée en erreur et réessayée au prochain déclenchement seulement, pas avant : pour un appel qui doit aboutir, un événement et son abonné durable relancent plus tôt.

Ce qui est compté

Chaque appel lancé depuis le code est noté dans l'historique avec l'origine « Code », ses jetons et son résultat, comme un clic. runPrompt accepte un userId pour attribuer l'appel à un compte ; une tâche n'en passe pas.

Notes

  • durable: true relance un abonné jusqu'à douze fois, environ une journée, puis l'abandonne : la livraison reste visible dans la page Événements de l'admin, qui la relance. Voir Livraison durable.
  • Sur une plateforme serverless, aucune tâche planifiée ne tourne, et les abonnés durables perdent leurs reprises. Voir Cron.
  • La charge d'un événement ne porte que des identifiants. L'abonné relit le contenu par son identifiant au moment de l'appel : il n'envoie jamais au modèle une copie périmée.

Sur cette page