Isoler la dernière réponse d’un fil d’e-mails

Séparer le message qui vient d’arriver de l’historique recopié en dessous.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la limite cherchée n’est pas à déduire : c’est une chaîne que le client de messagerie a écrite avant d’envoyer. Les deux bibliothèques du domaine sont `email_reply_parser` en Python et `email-reply-parser` en JavaScript, et cette fiche les nomme sans les appeler, pour une raison qu’elle mesure. Sur le même fil français, la première laisse « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : » dans la réponse, faute de connaître l’en-tête français ; la seconde le coupe, mais retire aussi la signature, parce que « Bien à vous, » figure dans sa liste de formules de politesse quand « Best, » n’y est pas. Deux extraits qui ne répondent pas la même chose du même message ne servent à rien sur une page qui montre les deux : les marqueurs sont donc écrits ici, les français compris, et la signature n’est pas touchée — un nom seul sur une ligne est parfois tout le message, et la retirer est le sujet d’une autre fiche. Montez à N3 pour les fils, et seulement pour eux, où le rapport dit que la réponse a été écrite dans la citation.

Le marqueur que le client de messagerie a écrit, français compris

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-the-latest-reply-from-an-email-thread/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Garder la dernière réponse d'un fil d'e-mail, et jeter le fil qui suit.

Niveau N0. Un client de messagerie n'envoie pas la réponse seule : il envoie
la réponse, puis toute la conversation en dessous, marquée par quelque chose
qu'il a écrit lui-même — un « > » devant chaque ligne, un trait de
soulignés, un bloc d'en-têtes, ou la phrase « Le 10 octobre 2026 à 13:55,
Marie Martin a écrit : ». Ces marqueurs sont ce que ce niveau cherche, et la
réponse est ce qui se trouve au-dessus du premier d'entre eux.

Les bibliothèques qui font ce travail sont `email-reply-parser` en JavaScript
et `email_reply_parser` en Python. Elles ont été écrites pour des fils
anglais, et cela se voit : sur le fil français du test de cette fiche, celle
de Python laisse « Le 10 octobre 2026 à 13:55, Marie Martin
<marie@exemple.fr> a écrit : » dans la réponse, et celle de JavaScript retire
la signature qu'elle garde sur le fil anglais. Deux extraits qui ne répondent
pas la même chose du même message ne servent à rien sur une page qui montre
les deux : les marqueurs sont donc écrits ici, les français compris.

Une chose n'est délibérément pas faite : retirer la signature. « Bien à vous,
Jean Dupont » fait partie de ce que Jean a écrit, et une ligne qui ne porte
qu'un nom est parfois tout le message. La coupe a lieu à la citation, et
s'arrête là.
"""

from __future__ import annotations

import re

# What a mail client writes when it quotes, and what is under each marker.
# « quoted » means the old message is prefixed line by line, so what is not
# prefixed under it was written by the sender; « block » means it is copied
# as is, and nothing tells it apart from a reply.
MARKERS = [
    # A quoted line, the oldest convention of all.
    ("quoted", re.compile(r"^\s*>")),
    # Outlook's separator, and the header block that follows it.
    ("block", re.compile(r"^\s*_{5,}\s*$")),
    ("block", re.compile(r"^\s*(De|From|Expéditeur)\s*:\s+\S")),
    # « -----Message d'origine----- » and its translations.
    ("block", re.compile(r"^\s*-{2,}\s*(Message d'origine|Message transféré"
                         r"|Original Message|Forwarded message)\s*-{2,}\s*$",
                         re.IGNORECASE)),
]

# « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : », which Gmail wraps
# over two lines. Opening and closing are therefore looked for separately,
# and the closing is tried on one line, then two, then three.
ATTRIBUTION_OPENS = re.compile(r"^\s*(Le|On)\s+\S")
ATTRIBUTION_CLOSES = re.compile(r"(a écrit|wrote)\s*:\s*$")
ATTRIBUTION_LINES = 3

# Every mail client writes a date or an address in that line. Requiring one
# keeps « Le client a écrit : », which is a sentence and not a marker, from
# cutting the message at its first line.
ATTRIBUTION_DATES = re.compile(r"\d|@")

LINE_END = re.compile(r"\r\n|\r|\n")

# Characters the two languages do not class alike: a byte-order mark is a space
# for JavaScript and not for Python, and the C1 separators are the reverse.
# None of them belongs in a reply, and leaving them in would make the two
# extracts of this entry cut at different places.
ODD_SPACES = re.compile(r"[\ufeff\x1c-\x1f\x85]")


def extract_reply(message) -> dict:
    """
    The text above the first quote marker, and what was found under it.

    `reason` is set when the reply looks like it was written under the quote or
    inside it: the cut then returns almost nothing, and « almost nothing » must
    not be handed back as « the reply was empty ». What the attribution line
    itself holds is not counted as text written under the quote — it is the
    dressing of the quote, and it is sixty-nine characters long, which is
    longer than most business replies.

    `quoted_from_line` counts lines in the message split on CR, LF and CRLF,
    and on those only — not the way `str.splitlines()` or `String.split` would
    do it. A caller that uses the number splits the same way, with the regular
    expression named below.
    """
    if not isinstance(message, str):
        return _report("", None, f"expected text, not {type(message).__name__}")

    # Split on the three line endings a mail carries, and on those only:
    # str.splitlines() also cuts on a form feed and on U+2028, which the
    # JavaScript side does not, and the two would stop answering alike.
    lines = LINE_END.split(ODD_SPACES.sub("", message))
    cut, kind, dressing = _first_marker(lines)
    if cut is None:
        return _report(message.strip(), None, None)

    reply = "\n".join(lines[:cut]).strip()
    if kind == "quoted" and _unquoted_under(lines[cut + dressing:]) > len(reply):
        return _report(reply, cut, "more text was written under the quote than above it")
    return _report(reply, cut, None)


def _first_marker(lines):
    """
    Where the quoted thread begins, how the old message is marked, and how many
    lines the marker itself takes.

    The third number is what keeps the attribution out of the count of what was
    written under the quote: a `>` prefix is already dropped by the filter, an
    attribution is not.
    """
    for index, line in enumerate(lines):
        for kind, marker in MARKERS:
            if marker.search(line):
                return index, kind, 0
        if ATTRIBUTION_OPENS.match(line):
            for length in range(1, ATTRIBUTION_LINES + 1):
                window = " ".join(lines[index:index + length]).rstrip()
                if ATTRIBUTION_CLOSES.search(window) and ATTRIBUTION_DATES.search(window):
                    return index, "quoted", length
    return None, None, 0


def _unquoted_under(lines) -> int:
    """How much text under the cut carries no quote prefix."""
    loose = [line for line in lines if line.strip() and not line.lstrip().startswith(">")]
    return len("\n".join(loose).strip())


def _report(reply: str, cut, reason: str | None) -> dict:
    return {"reply": reply, "quoted_from_line": cut, "reason": reason}

JavaScript

snippets/extract-the-latest-reply-from-an-email-thread/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Garder la dernière réponse d'un fil d'e-mail, et jeter le fil qui suit.
 *
 * Niveau N0. Un client de messagerie n'envoie pas la réponse seule : il envoie
 * la réponse, puis toute la conversation en dessous, marquée par quelque chose
 * qu'il a écrit lui-même — un « > » devant chaque ligne, un trait de
 * soulignés, un bloc d'en-têtes, ou la phrase « Le 10 octobre 2026 à 13:55,
 * Marie Martin a écrit : ». Ces marqueurs sont ce que ce niveau cherche, et la
 * réponse est ce qui se trouve au-dessus du premier d'entre eux.
 *
 * Les bibliothèques qui font ce travail sont `email-reply-parser` en JavaScript
 * et `email_reply_parser` en Python. Elles ont été écrites pour des fils
 * anglais, et cela se voit : sur le fil français du test de cette fiche, celle
 * de Python laisse « Le 10 octobre 2026 à 13:55, Marie Martin
 * <marie@exemple.fr> a écrit : » dans la réponse, et celle de JavaScript retire
 * la signature qu'elle garde sur le fil anglais. Deux extraits qui ne répondent
 * pas la même chose du même message ne servent à rien sur une page qui montre
 * les deux : les marqueurs sont donc écrits ici, les français compris.
 *
 * Une chose n'est délibérément pas faite : retirer la signature. « Bien à vous,
 * Jean Dupont » fait partie de ce que Jean a écrit, et une ligne qui ne porte
 * qu'un nom est parfois tout le message. La coupe a lieu à la citation, et
 * s'arrête là.
 */

// What a mail client writes when it quotes, and what is under each marker.
// « quoted » means the old message is prefixed line by line, so what is not
// prefixed under it was written by the sender; « block » means it is copied
// as is, and nothing tells it apart from a reply.
export const MARKERS = [
  // A quoted line, the oldest convention of all.
  ['quoted', /^\s*>/],
  // Outlook's separator, and the header block that follows it.
  ['block', /^\s*_{5,}\s*$/],
  ['block', /^\s*(De|From|Expéditeur)\s*:\s+\S/],
  // « -----Message d'origine----- » and its translations.
  ['block', /^\s*-{2,}\s*(Message d'origine|Message transféré|Original Message|Forwarded message)\s*-{2,}\s*$/i],
];

// « Le 10 octobre 2026 à 13:55, Marie Martin a écrit : », which Gmail wraps
// over two lines. Opening and closing are therefore looked for separately,
// and the closing is tried on one line, then two, then three.
const ATTRIBUTION_OPENS = /^\s*(Le|On)\s+\S/;
const ATTRIBUTION_CLOSES = /(a écrit|wrote)\s*:\s*$/;
const ATTRIBUTION_LINES = 3;

// Every mail client writes a date or an address in that line. Requiring one
// keeps « Le client a écrit : », which is a sentence and not a marker, from
// cutting the message at its first line.
const ATTRIBUTION_DATES = /\d|@/;

const LINE_END = /\r\n|\r|\n/;

// Characters the two languages do not class alike: a byte-order mark is a
// space for JavaScript and not for Python, and the C1 separators are the
// reverse. None of them belongs in a reply, and leaving them in would make the
// two extracts of this entry cut at different places.
const ODD_SPACES = /[\ufeff\x1c-\x1f\x85]/g;

/**
 * The text above the first quote marker, and what was found under it.
 *
 * `reason` is set when the reply looks like it was written under the quote or
 * inside it: the cut then returns almost nothing, and « almost nothing » must
 * not be handed back as « the reply was empty ». What the attribution line
 * itself holds is not counted as text written under the quote — it is the
 * dressing of the quote, and it is sixty-nine characters long, which is
 * longer than most business replies.
 *
 * `quoted_from_line` counts lines in the message split on CR, LF and CRLF, and
 * on those only — not the way `String.prototype.split(/\s/)` or Python's
 * `str.splitlines()` would do it. A caller that uses the number splits the
 * same way, with the regular expression named below.
 */
export function extractReply(message) {
  if (typeof message !== 'string') {
    return report('', null, `expected text, not ${typeof message}`);
  }

  const lines = message.replace(ODD_SPACES, '').split(LINE_END);
  const [cut, kind, dressing] = firstMarker(lines);
  if (cut === null) return report(message.trim(), null, null);

  const reply = lines.slice(0, cut).join('\n').trim();
  if (kind === 'quoted' && unquotedUnder(lines.slice(cut + dressing)) > reply.length) {
    return report(reply, cut, 'more text was written under the quote than above it');
  }
  return report(reply, cut, null);
}

/**
 * Where the quoted thread begins, how the old message is marked, and how many
 * lines the marker itself takes.
 *
 * The third number is what keeps the attribution out of the count of what was
 * written under the quote: a `>` prefix is already dropped by the filter, an
 * attribution is not.
 */
function firstMarker(lines) {
  for (let index = 0; index < lines.length; index += 1) {
    for (const [kind, marker] of MARKERS) {
      if (marker.test(lines[index])) return [index, kind, 0];
    }
    if (ATTRIBUTION_OPENS.test(lines[index])) {
      for (let length = 1; length <= ATTRIBUTION_LINES; length += 1) {
        const window = lines.slice(index, index + length).join(' ').replace(/\s+$/, '');
        if (ATTRIBUTION_CLOSES.test(window) && ATTRIBUTION_DATES.test(window)) {
          return [index, 'quoted', length];
        }
      }
    }
  }
  return [null, null, 0];
}

/** How much text under the cut carries no quote prefix. */
function unquotedUnder(lines) {
  const loose = lines.filter((line) => line.trim() && !line.trimStart().startsWith('>'));
  return loose.join('\n').trim().length;
}

function report(reply, quotedFromLine, reason) {
  return { reply, quoted_from_line: quotedFromLine, reason };
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Rien ne part sur le réseau : la coupe se fait dans votre navigateur, sur le texte que vous collez.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

1 passage attrapé

Ce que le code a repéré

Bonjour Marie, Le devis est signé, vous pouvez lancer la production. Bien à vous, Jean Dupont Le 10 octobre 2026 à 13:55, Marie Martin <marie@exemple.fr> a écrit : > Bonjour Jean, > > Pouvez-vous confirmer le devis DV-2026-118 avant vendredi ? > > Cordialement, > Marie

Ce qu’il renvoie

Bonjour Marie, Le devis est signé, vous pouvez lancer la production. Bien à vous, Jean Dupont

Ce qu’il en conclut

Réponse gardée, citation reconnue à la ligne 8

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le message est découpé dans votre processus et n’est transmis à personne
  • Un fil d’e-mails est une correspondance, et porte les données personnelles de gens qui ne vous ont rien demandé : ce niveau est celui qui les garde chez vous

Point de rupture

Une réponse écrite dans la citation n’est pas au-dessus du premier marqueur : sur un fil où « Oui, il est signé de ce matin. » répond sous la question citée, la coupe ne rend rien, et le rapport dit pourquoi. Témoin dans le même test : le fil ordinaire, répondu au-dessus, revient entier, signature comprise.

Quand passer au niveau suivant

Le rapport signale qu’il y a plus de texte sous la citation qu’au-dessus, c’est-à-dire que la réponse a été écrite dedans. Pour ces fils-là, et pour ceux-là seulement, le niveau N3 lit ce qu’aucun marqueur ne délimite.

Les quatre options

  1. RecommandéN0Le marqueur que le client de messagerie a écrit, français comprisGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Les numéros des lignes du fil, demandés au modèle et vérifiésmodéré · Une seconde

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Un modèle classique apprendrait à reconnaître ce qu’un programme a écrit noir sur blanc. Le seul outil du domaine qui apprend vraiment est `talon`, dont le module de signature est un classifieur entraîné sur des messages annotés ; il est en version 1.4.4, publiée le 24 août 2017, et il fige des versions de `scikit-learn` d’alors. Et il porterait le biais que cette fiche mesure : un classifieur entraîné sur des corpus anglais coupe le fil anglais et pas le fil français.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Le même argument, avec le budget en plus. Le traitement est appelé une fois par message sur une boîte aux lettres qui en reçoit des milliers par jour ; la mesure de cette fiche le situe à quelques microsecondes le message. Un modèle résident, même petit, coûte des ordres de grandeur de plus par message, pour retrouver une limite que le client de messagerie a écrite en clair.

N3Les numéros des lignes du fil, demandés au modèle et vérifiésmodéré · Une seconde

Les numéros des lignes du fil, demandés au modèle et vérifiés

API de LLM généralisteN3modéréUne seconde

Python

snippets/extract-the-latest-reply-from-an-email-thread/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Demander à un modèle quelles lignes du fil le dernier expéditeur a écrites.

Niveau N3. Il existe pour le seul cas que le niveau N0 ne sait pas trancher :
une réponse écrite non pas au-dessus de la citation mais à l'intérieur, ligne
à ligne, sous les phrases auxquelles elle répond. Aucun marqueur ne sépare
ces lignes de celles qui les entourent — le client n'en a écrit aucun — et un
modèle les lit comme un humain les lit.

La garde est ce qui rend ce niveau défendable, et elle est ici
particulièrement forte parce que la réponse demandée n'est pas du texte mais
des numéros de ligne. Le modèle reçoit le fil numéroté et rend les numéros
des lignes qu'il attribue au dernier expéditeur ; tout ce qui n'est pas une
ligne du message envoyé est écarté, et `dropped` dit lesquelles. La réponse
rendue est donc faite de lignes du fil, dans leur ordre, et jamais d'une
phrase que le modèle aurait trouvée plus vraisemblable.

Deux conditions d'exploitation. Le fil est coupé à un budget par la fin,
parce que la réponse cherchée est normalement près du début ; un fil plus
long que le budget dont la réponse est tout en bas est coupé avant d'être lu.
Et le coût est un appel par message : ce niveau est pour les fils que le
niveau N0 a signalés, pas pour une boîte aux lettres.
"""

from __future__ import annotations

import json
import re

# The budget, in characters. A thread that does not fit is cut, not refused.
MAX_CHARACTERS = 6000

# A single code fence around the whole answer is a common shape, and refusing
# it would buy another call for nothing.
FENCE = re.compile(r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z", re.DOTALL)

LINE_END = re.compile(r"\r\n|\r|\n")

MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

PROMPT = (
    "Here is an email thread, one numbered line per line.\n"
    "Return the numbers of the lines written by the person who sent this "
    "message, and not the lines of the messages quoted under it.\n"
    'Answer with JSON only: {{"lines": [numbers]}}.\n\n'
    "Thread:\n{thread}"
)


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class ReadingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def read_reply(message: str, client=None, *, attempts: int = 3) -> dict:
    """
    The lines of the thread a model attributes to the last sender.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()
    thread = message[:MAX_CHARACTERS]
    lines = LINE_END.split(thread)
    answer = _ask(client, lines, attempts)

    kept, dropped = [], []
    for number in answer.get("lines", []) if isinstance(answer, dict) else []:
        if isinstance(number, int) and 1 <= number <= len(lines) \
                and not lines[number - 1].lstrip().startswith(">"):
            kept.append(number)
        else:
            # Not a line of what was sent, or a line of the quoted thread.
            dropped.append(number)

    kept = sorted(set(kept))
    reply = "\n".join(lines[number - 1] for number in kept).strip()
    return {"source": "model", "reply": reply, "lines": kept, "dropped": dropped,
            "characters_sent": len(thread)}


def _ask(client, lines, attempts: int) -> dict:
    numbered = "\n".join(f"{number}: {line}" for number, line in enumerate(lines, 1))
    prompt = PROMPT.format(thread=numbered)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=prompt, temperature=0)
            if not isinstance(answer, str):  # a refusal comes back as no content
                last_error = ValueError("the model answered no text")
                continue
            fenced = FENCE.match(answer)
            return json.loads(fenced.group(1) if fenced else answer)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ReadingUnavailable(str(last_error))

JavaScript

snippets/extract-the-latest-reply-from-an-email-thread/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Demander à un modèle quelles lignes du fil le dernier expéditeur a écrites.
 *
 * Niveau N3. Il existe pour le seul cas que le niveau N0 ne sait pas trancher :
 * une réponse écrite non pas au-dessus de la citation mais à l'intérieur, ligne
 * à ligne, sous les phrases auxquelles elle répond. Aucun marqueur ne sépare
 * ces lignes de celles qui les entourent — le client n'en a écrit aucun — et un
 * modèle les lit comme un humain les lit.
 *
 * La garde est ce qui rend ce niveau défendable, et elle est ici
 * particulièrement forte parce que la réponse demandée n'est pas du texte mais
 * des numéros de ligne. Le modèle reçoit le fil numéroté et rend les numéros
 * des lignes qu'il attribue au dernier expéditeur ; tout ce qui n'est pas une
 * ligne du message envoyé est écarté, et `dropped` dit lesquelles. La réponse
 * rendue est donc faite de lignes du fil, dans leur ordre, et jamais d'une
 * phrase que le modèle aurait trouvée plus vraisemblable.
 *
 * Deux conditions d'exploitation. Le fil est coupé à un budget par la fin,
 * parce que la réponse cherchée est normalement près du début ; un fil plus
 * long que le budget dont la réponse est tout en bas est coupé avant d'être lu.
 * Et le coût est un appel par message : ce niveau est pour les fils que le
 * niveau N0 a signalés, pas pour une boîte aux lettres.
 */

// The budget, in characters. A thread that does not fit is cut, not refused.
export const MAX_CHARACTERS = 6000;

// A single code fence around the whole answer is a common shape, and refusing
// it would buy another call for nothing.
const FENCE = /^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/;

const LINE_END = /\r\n|\r|\n/;

export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

const PROMPT = [
  'Here is an email thread, one numbered line per line.',
  'Return the numbers of the lines written by the person who sent this message,'
  + ' and not the lines of the messages quoted under it.',
  'Answer with JSON only: {"lines": [numbers]}.',
  '',
  'Thread:',
].join('\n');

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class ReadingUnavailable extends Error {}

/**
 * The lines of the thread a model attributes to the last sender.
 *
 * @param {string} message
 * @param {{client?: {complete: Function}, attempts?: number}} [options]
 */
export async function readReply(message, { client, attempts = 3 } = {}) {
  client ??= await providerClient();
  const thread = message.slice(0, MAX_CHARACTERS);
  const lines = thread.split(LINE_END);
  const answer = await ask(client, lines, attempts);

  const kept = [];
  const dropped = [];
  const asked = answer && typeof answer === 'object' ? answer.lines ?? [] : [];
  for (const number of Array.isArray(asked) ? asked : []) {
    if (Number.isInteger(number) && number >= 1 && number <= lines.length
        && !lines[number - 1].trimStart().startsWith('>')) {
      kept.push(number);
    } else {
      // Not a line of what was sent, or a line of the quoted thread.
      dropped.push(number);
    }
  }

  const numbers = [...new Set(kept)].sort((a, b) => a - b);
  const reply = numbers.map((number) => lines[number - 1]).join('\n').trim();
  return { source: 'model', reply, lines: numbers, dropped, characters_sent: thread.length };
}

async function ask(client, lines, attempts) {
  const numbered = lines.map((line, index) => `${index + 1}: ${line}`).join('\n');
  const prompt = `${PROMPT}\n${numbered}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({ prompt, temperature: 0 });
      if (typeof answer !== 'string') {
        lastError = new Error('the model answered no text'); // a refusal has no content
        continue;
      }
      const fenced = FENCE.exec(answer);
      return JSON.parse(fenced ? fenced[1] : answer);
    } catch (error) {
      lastError = error;
    }
  }
  throw new ReadingUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert d’une correspondance à un sous-traitant, avec l’encadrement contractuel que cela suppose, et pour des gens qui ne l’ont pas choisi
  • Localisation du traitement à vérifier auprès du fournisseur

Point de rupture

La garde refuse les lignes préfixées par « > », pas les lignes d’un message recopié sans préfixe : sur un fil Outlook, les lignes de l’ancien message passent la garde et entrent dans la réponse. Le témoin est dans le même test : sur un fil cité ligne à ligne, la ligne citée est refusée et rendue dans `dropped`.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées