Résumer un document long

Obtenir en quelques phrases ce que dit un document trop long pour être lu en entier.

Oui, un modèle généraliste se justifie

élevéPlus d’une secondeVos données partent chez un tiers

Le verdict

N3, parce que résumer c’est reformuler, et que c’est le seul niveau qui écrive une phrase que personne n’a écrite sans vous coûter un service d’inférence à exploiter ni une découpe en plusieurs passes — la fenêtre du modèle d’exemple fait 1 047 576 jetons, de sorte qu’un rapport de cinquante pages part en un appel et en entier —, et dont la forme de sortie se demande dans l’invite au lieu d’être celle qu’a apprise le modèle. Pour un lecteur francophone, cela départage aussi N2 : le point de contrôle qu’il charge ne résume que l’anglais, et le seul équivalent multilingue courant, mT5 affiné sur XL-Sum, est sous licence interdisant l’usage commercial — tant que le modèle n’est pas changé, N2 n’est pas une option sur un document français. Le prix est à dire à voix haute : le document part chez un tiers, chaque appel se paie, deux appels sur le même document ne sont pas garantis de rendre le même résumé, et vos tests ne savent vérifier que la forme — celui de cette fiche laisse passer un résumé impeccable qui invente un conseil d’administration. Si votre document ne supporte pas ce risque, la réponse n’est pas un meilleur prompt : c’est quelqu’un qui relit la source à côté du résumé, ou N0, qui ne peut rien inventer parce qu’il ne sait que citer, au prix de ne jamais dire ce que le document démontre.

Résumé par appel à un modèle généraliste, réponse en JSON

Oui, un modèle généraliste se justifie

API de LLM généralisteN3élevéPlus d’une seconde

Python

snippets/summarise-a-long-document/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.
"""
Résumer un document long en interrogeant un modèle généraliste.

Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
fiche le recommande.

Ce qu'il apporte sur N2 est réel : aucun poids à héberger, aucune machine à
garder chaude, et une forme de sortie fixée dans l'invite — trois phrases,
une liste de points, ou les deux — plutôt qu'apprise dans les poids. Que la
réponse respecte cette forme est vérifié plus bas, pas supposé.

Ce qu'elle coûte est dans ce fichier. Décider ce qu'un appel a le droit de
coûter, parce que le fournisseur facture au jeton et qu'un document que
personne ne voulait envoyer est de l'argent perdu. Réessayer, parce que
l'appel passe par le réseau. Analyser une réponse qui n'est que probablement le JSON demandé.
Refuser une réponse de la mauvaise forme plutôt que d'en passer la moitié à
l'appelant. Cette plomberie est ce que vos tests peuvent couvrir.

Ce qu'aucun test d'ici ne peut couvrir : si le résumé est vrai du document.
Rien de ce qui suit ne sait distinguer d'une bonne phrase une phrase fluide
que le document n'a jamais soutenue. Voyez le test.
"""

from __future__ import annotations

import json

PROMPT = (
    "Summarise the document below in at most {sentences} sentences.\n"
    "Use only what the document says, and add nothing to it.\n"
    "Answer with JSON only: an object with the key `summary`, a string, and\n"
    "the key `key_points`, a list of short strings.\n\n"
    "Document:\n{document}"
)

# The default ceiling only guarantees that the document fits the window of the
# example model below: that window is 1,047,576 tokens, and a token never
# stands for less than one character, so a million characters cannot overflow
# it. It is not a cost ceiling. Pass `max_characters` to set what one call may
# cost you — the report of fifty pages this entry is about is a few hundred
# thousand characters, and goes in one call either way.
MAX_CHARACTERS = 1_000_000

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts


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 SummaryUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def summarise(document: str, client=None, *, max_sentences: int = 3, attempts: int = 3,
              max_characters: int = MAX_CHARACTERS) -> dict:
    """
    Return `{"summary": str, "key_points": list[str]}`.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.

    `max_characters` is refused before the call, not after: the provider bills
    the input whether the answer is useful or not.
    """
    if len(document) > max_characters:
        raise ValueError(f"document longer than {max_characters} characters")
    if max_sentences < 1:
        raise ValueError("max_sentences must be at least 1")

    # An empty document has no summary, and asking for one costs the same as
    # asking for a real one.
    if not document.strip():
        return {"summary": "", "key_points": []}

    prompt = PROMPT.format(sentences=max_sentences, document=document)
    return _ask(client or ProviderClient(), prompt, attempts)


def _ask(client, prompt: str, attempts: int) -> dict:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            # Temperature zero, the low end of the range, which the provider
            # documents as more focused and deterministic. It does not promise
            # that two identical calls agree.
            answer = client.complete(prompt=prompt, temperature=0)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
            continue
        try:
            return _decode(answer)
        except ValueError as error:  # an unusable answer is asked for again
            last_error = error
    raise SummaryUnavailable(str(last_error))


def _decode(answer) -> dict:
    """
    Accept only the shape that was asked for.

    Returning a half-built answer would hand the caller a summary that is
    silently empty, which reads exactly like a document with nothing in it.
    """
    if not isinstance(answer, str):  # the SDK types the content as optional
        raise ValueError("the model returned no text")
    # A JSON answer wrapped whole in one code fence is read; nothing else is:
    # a fence opened and never closed, or prose around it, is a failed answer.
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        text = text[3:-3].removeprefix("json")
    try:
        parsed = json.loads(text)
    except RecursionError as error:
        raise ValueError("the answer is nested too deep to read") from error
    if not isinstance(parsed, dict):
        raise ValueError("the model answered something that is not an object")
    summary = parsed.get("summary")
    if not isinstance(summary, str) or not summary.strip():
        raise ValueError("the model answered without a summary")
    points = parsed.get("key_points", [])
    if not isinstance(points, list) or not all(isinstance(point, str) for point in points):
        raise ValueError("the model answered with key points that are not a list of strings")
    return {"summary": summary.strip(), "key_points": points}

JavaScript

snippets/summarise-a-long-document/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.
/**
 * Résumer un document long en interrogeant un modèle généraliste.
 *
 * Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
 * fiche le recommande.
 *
 * Ce qu'il apporte sur N2 est réel : aucun poids à héberger, aucune machine à
 * garder chaude, et une forme de sortie fixée dans l'invite — trois phrases,
 * une liste de points, ou les deux — plutôt qu'apprise dans les poids. Que la
 * réponse respecte cette forme est vérifié plus bas, pas supposé.
 *
 * Ce qu'elle coûte est dans ce fichier. Décider ce qu'un appel a le droit de
 * coûter, parce que le fournisseur facture au jeton et qu'un document que
 * personne ne voulait envoyer est de l'argent perdu. Réessayer, parce que
 * l'appel passe par le réseau. Analyser une réponse qui n'est que probablement le JSON demandé.
 * Refuser une réponse de la mauvaise forme plutôt que d'en passer la moitié à
 * l'appelant. Cette plomberie est ce que vos tests peuvent couvrir.
 *
 * Ce qu'aucun test d'ici ne peut couvrir : si le résumé est vrai du document.
 * Rien de ce qui suit ne sait distinguer d'une bonne phrase une phrase fluide
 * que le document n'a jamais soutenue. Voyez le test.
 */

const PROMPT = [
  'Summarise the document below in at most {sentences} sentences.',
  'Use only what the document says, and add nothing to it.',
  'Answer with JSON only: an object with the key `summary`, a string, and',
  'the key `key_points`, a list of short strings.',
  '',
  'Document:',
].join('\n');

// The default ceiling only guarantees that the document fits the window of the
// example model below: that window is 1,047,576 tokens, and a token never
// stands for less than one character, so a million characters cannot overflow
// it. It is not a cost ceiling. Pass `maxCharacters` to set what one call may
// cost you — the report of fifty pages this entry is about is a few hundred
// thousand characters, and goes in one call either way.
export const MAX_CHARACTERS = 1_000_000;

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

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 SummaryUnavailable extends Error {}

/**
 * Return `{ summary, keyPoints }`.
 *
 * @param {string} document
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {number} [options.maxSentences]
 * @param {number} [options.attempts]
 * @param {number} [options.maxCharacters] refused before the call, not after:
 *   the provider bills the input whether the answer is useful or not
 */
export async function summarise(
  document,
  { client, maxSentences = 3, attempts = 3, maxCharacters = MAX_CHARACTERS } = {},
) {
  // Counted in code points, as Python counts characters.
  if ([...document].length > maxCharacters) {
    throw new RangeError(`document longer than ${maxCharacters} characters`);
  }
  if (!(maxSentences >= 1)) throw new RangeError('maxSentences must be at least 1');

  // An empty document has no summary, and asking for one costs the same as
  // asking for a real one.
  if (!document.trim()) return { summary: '', keyPoints: [] };

  const prompt = `${PROMPT.replace('{sentences}', String(maxSentences))}\n${document}`;
  return ask(client ?? (await providerClient()), prompt, attempts);
}

async function ask(client, prompt, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    let answer;
    try {
      // Temperature zero, the low end of the range, which the provider
      // documents as more focused and deterministic. It does not promise
      // that two identical calls agree.
      answer = await client.complete({ prompt, temperature: 0 });
    } catch (error) {
      lastError = error; // any provider failure is retried
      continue;
    }
    try {
      return decode(answer);
    } catch (error) {
      lastError = error; // an unusable answer is asked for again
    }
  }
  throw new SummaryUnavailable(String(lastError));
}

/**
 * Accept only the shape that was asked for.
 *
 * Returning a half-built answer would hand the caller a summary that is
 * silently empty, which reads exactly like a document with nothing in it.
 */
function decode(answer) {
  if (typeof answer !== 'string') throw new Error('the model returned no text');
  // A JSON answer wrapped whole in one code fence is read; nothing else is: a
  // fence opened and never closed, or prose around it, is a failed answer.
  const trimmed = answer.trim();
  const fenced = trimmed.startsWith('```') && trimmed.endsWith('```') && trimmed.split('```').length === 3;
  const parsed = JSON.parse(fenced ? trimmed.slice(3, -3).replace(/^json/, '') : trimmed);
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error('the model answered something that is not an object');
  }
  const { summary, key_points: points = [] } = parsed;
  if (typeof summary !== 'string' || !summary.trim()) {
    throw new Error('the model answered without a summary');
  }
  if (!Array.isArray(points) || !points.every((point) => typeof point === 'string')) {
    throw new Error('the model answered with key points that are not a list of strings');
  }
  return { summary: summary.trim(), keyPoints: points };
}

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

La réponse du modèle est simulée par le double local qui sert aux tests. Ce qui est calculé ici, c’est ce que le code envoie, ce qu’il refuse, ce qu’il réessaie et ce qu’il accepte.

  1. Un rapport de trois phrases

    Donné

    L’équipe support a migré le système de tickets vers une nouvelle plateforme en mars. Chaque agent a été formé pendant les deux semaines précédant la bascule. L’ancienne plateforme est restée disponible en lecture seule pendant un mois.

    Ce qu’il renvoie

    Le système de tickets a migré vers une nouvelle plateforme en mars. — agents formés avant la bascule — ancienne plateforme en lecture seule

    1 appel envoyé, 235 caractères de document partis chez le fournisseur.

  2. Un document vide

    Donné

    (rien, ou des espaces)

    Ce qu’il renvoie

    Aucun appel envoyé, donc rien dépensé.

  3. Un document au-dessus du plafond fixé

    Donné

    un document de 2 001 caractères, pour un plafond fixé à 2 000

    Ce qu’il renvoie

    Refusé avant le premier appel

    document longer than 2000 characters

    Rien n’est parti, rien n’est dû.

  4. Le fournisseur échoue deux fois

    Donné

    le même rapport, mais les deux premiers appels échouent

    Ce qu’il renvoie

    Le système de tickets a migré vers une nouvelle plateforme en mars. — agents formés avant la bascule — ancienne plateforme en lecture seule

    3 appels envoyés : les deux premiers ont échoué, le troisième a répondu.

  5. Le modèle répond en prose

    Donné

    le même rapport ; le modèle répond « Bien sûr ! Voici un résumé… »

    Ce qu’il renvoie

    Réponse rejetée

    Le modèle a répondu en prose là où le code demandait du JSON. Le code refuse plutôt que de rendre un résumé vide.

    3 appels envoyés et facturés, aucune réponse exploitable.

  6. Le modèle résume ce que le document ne dit pas

    Donné

    le même rapport, qui ne parle d’aucun ticket perdu

    Ce qu’il renvoie

    La migration a fait perdre trois jours de tickets. — incident de migration

    1 appel envoyé, 235 caractères de document partis chez le fournisseur.

    Là, ça casseLe code rend ce résumé sans broncher : il vérifie la forme de la réponse, jamais sa vérité. Aucun test de cette fiche ne peut attraper une phrase fluide et fausse.

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 élevée

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

  • Transfert du document à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Les données personnelles de tiers et les secrets d’affaires que le document contient partent avec lui
  • Localisation du traitement et conservation des requêtes à vérifier auprès du fournisseur
  • Ne vous dispense pas de vos propres obligations d’information et de minimisation

Point de rupture

L’instruction « n’utilise que ce que dit le document » est une demande, pas une contrainte. Le test fait rendre au double local du JSON valide, de la bonne forme, de la bonne longueur, fluide, qui annonce un gain sur le temps de traitement des tickets et une seconde phase approuvée par le conseil : ni le conseil ni ce gain ne figurent dans le document. Tous les contrôles de l’extrait passent, parce qu’ils portent tous sur la forme ; qu’un résumé découle de sa source est une question de sens, et aucun analyseur ne la tranche. Ce qui est démontré là, c’est que rien dans l’extrait n’arrête une invention, non qu’un fournisseur en produise une sur ce document.

Quand passer au niveau suivant

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

Les quatre options

  1. N0Résumé extractif : densité des termes et bonus de positionGratuit · Instantané
  2. N1Notation supervisée des phrases sur cinq traits de surfacenégligeable · Instantané
  3. N2Modèle de résumé abstractif auto-hébergé (anglais), en autant de passes qu’il fautmodéré · Plus d’une seconde
  4. RecommandéN3Résumé par appel à un modèle généraliste, réponse en JSONélevé · Plus d’une seconde

Les autres options

N0Résumé extractif : densité des termes et bonus de positionGratuit · Instantané

Résumé extractif : densité des termes et bonus de position

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/summarise-a-long-document/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Résumer un document long en choisissant ses meilleures phrases.

Niveau N0. Extractif : chaque phrase du résumé figure mot pour mot dans la
source, parce que ce code n'écrit jamais un mot, il ne fait que choisir.

Deux signaux classiques, et rien d'autre.

La fréquence des termes. Un mot sur lequel le document revient sans cesse est
ce dont le document parle, et une phrase dense en mots de ce genre porte donc
davantage du sujet qu'une phrase faite de mots de liaison. Diviser par la
longueur de la phrase mesure une densité plutôt qu'un volume, ce qui empêche
une phrase longue de gagner par sa taille.

La position. Un auteur énonce son sujet tôt. Un petit bonus qui décroît avec
le rang de la phrase encode cette habitude, sans livrer pour autant le résumé
au premier paragraphe.
"""

import re
import unicodedata

# A sentence ends at a full stop, question or exclamation mark followed by
# whitespace, or at a line break, so a transcript or a list without final
# punctuation is still cut into lines. Abbreviations will fool this, and so
# will prose hard-wrapped at a fixed width, which has to be unwrapped first; a
# real corpus needs a better splitter, a separate problem from choosing.
SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\s*\n\s*")

# Letters and digits only: `[^\W_]` is `\w` without the underscore, so
# accented words survive and punctuation does not.
WORD = re.compile(r"[^\W_]+")


def words(sentence: str) -> list[str]:
    """Lowercase words, composed first: a decomposed `é` would split the word."""
    return WORD.findall(unicodedata.normalize("NFC", sentence).lower())

# Words too common to say anything about the subject of a document.
STOPWORDS = frozenset(
    "a an and are as at be been but by for from had has have in into is it its "
    "of on or that the their there they this to was were which will with".split()
)

# How much the opening of the document is worth. Large enough to break a tie
# between two equally dense sentences, too small to win on its own.
LEAD_BONUS = 0.15


def split_sentences(text: str) -> list[str]:
    """Cut the document into sentences, dropping empty ones."""
    parts = SENTENCE_END.split(text.strip())
    return [part.strip() for part in parts if part.strip()]


def _term_weights(sentences: list[str]) -> dict[str, float]:
    """Count content words, then scale so the most frequent one weighs one."""
    counts: dict[str, int] = {}
    for sentence in sentences:
        for word in words(sentence):
            if len(word) > 2 and word not in STOPWORDS:
                counts[word] = counts.get(word, 0) + 1
    if not counts:
        return {}
    most = max(counts.values())
    return {word: count / most for word, count in counts.items()}


def score_sentences(sentences: list[str]) -> list[float]:
    """Density in the document's own vocabulary, plus the position bonus."""
    weights = _term_weights(sentences)
    scores = []
    for index, sentence in enumerate(sentences):
        found = words(sentence)
        # Accumulated in a plain loop rather than with `sum`, which since
        # Python 3.12 compensates rounding error on floats. That is the better
        # answer, but it is not the answer JavaScript gives, and the two
        # versions of this snippet have to rank sentences identically.
        total = 0.0
        for word in found:
            total += weights.get(word, 0.0)
        density = total / len(found) if found else 0.0
        scores.append(density + LEAD_BONUS / (index + 1))
    return scores


def summarise(text: str, max_sentences: int = 3) -> str:
    """
    Return the best sentences, in the order the document puts them.

    Ordering the summary by score would read as a list of quotations. Keeping
    document order keeps the sequence the author chose, which is the only part
    of the argument an extractive summary can preserve.
    """
    if max_sentences < 0:
        raise ValueError("max_sentences cannot be negative")
    sentences = split_sentences(text)
    scores = score_sentences(sentences)
    # Sorting is stable, so two identical scores keep their document order.
    ranked = sorted(range(len(sentences)), key=lambda i: -scores[i])
    chosen = sorted(ranked[:max_sentences])
    return " ".join(sentences[index] for index in chosen)

JavaScript

snippets/summarise-a-long-document/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Résumer un document long en choisissant ses meilleures phrases.
 *
 * Niveau N0. Extractif : chaque phrase du résumé figure mot pour mot dans la
 * source, parce que ce code n'écrit jamais un mot, il ne fait que choisir.
 *
 * Deux signaux classiques, et rien d'autre.
 *
 * La fréquence des termes. Un mot sur lequel le document revient sans cesse est
 * ce dont le document parle, et une phrase dense en mots de ce genre porte donc
 * davantage du sujet qu'une phrase faite de mots de liaison. Diviser par la
 * longueur de la phrase mesure une densité plutôt qu'un volume, ce qui empêche
 * une phrase longue de gagner par sa taille.
 *
 * La position. Un auteur énonce son sujet tôt. Un petit bonus qui décroît avec
 * le rang de la phrase encode cette habitude, sans livrer pour autant le résumé
 * au premier paragraphe.
 */

// A sentence ends at a full stop, question or exclamation mark followed by
// whitespace, or at a line break, so a transcript or a list without final
// punctuation is still cut into lines. Abbreviations will fool this, and so
// will prose hard-wrapped at a fixed width, which has to be unwrapped first; a
// real corpus needs a better splitter, a separate problem from choosing.
const SENTENCE_END = /(?<=[.!?])\s+|\s*\n\s*/;

// Letters and digits only, so accented words survive and punctuation does
// not. The Python counterpart writes the same class as `[^\W_]`.
const WORD = /[\p{L}\p{N}]+/gu;

/** Lowercase words, composed first: a decomposed `é` would split the word. */
function words(sentence) {
  return sentence.normalize('NFC').toLowerCase().match(WORD) ?? [];
}

// Words too common to say anything about the subject of a document.
const STOPWORDS = new Set(
  ('a an and are as at be been but by for from had has have in into is it its ' +
    'of on or that the their there they this to was were which will with').split(' '),
);

// How much the opening of the document is worth. Large enough to break a tie
// between two equally dense sentences, too small to win on its own.
const LEAD_BONUS = 0.15;

/** Cut the document into sentences, dropping empty ones. */
export function splitSentences(text) {
  return text
    .trim()
    .split(SENTENCE_END)
    .map((part) => part.trim())
    .filter(Boolean);
}

/** Count content words, then scale so the most frequent one weighs one. */
function termWeights(sentences) {
  const counts = new Map();
  for (const sentence of sentences) {
    for (const word of words(sentence)) {
      if (word.length > 2 && !STOPWORDS.has(word)) {
        counts.set(word, (counts.get(word) ?? 0) + 1);
      }
    }
  }
  if (counts.size === 0) return new Map();
  const most = Math.max(...counts.values());
  return new Map([...counts].map(([word, count]) => [word, count / most]));
}

/** Density in the document's own vocabulary, plus the position bonus. */
export function scoreSentences(sentences) {
  const weights = termWeights(sentences);
  return sentences.map((sentence, index) => {
    const found = words(sentence);
    let total = 0;
    for (const word of found) total += weights.get(word) ?? 0;
    const density = found.length ? total / found.length : 0;
    return density + LEAD_BONUS / (index + 1);
  });
}

/**
 * Return the best sentences, in the order the document puts them.
 *
 * Ordering the summary by score would read as a list of quotations. Keeping
 * document order keeps the sequence the author chose, which is the only part
 * of the argument an extractive summary can preserve.
 */
export function summarise(text, maxSentences = 3) {
  if (maxSentences < 0) throw new RangeError('maxSentences cannot be negative');
  const sentences = splitSentences(text);
  const scores = scoreSentences(sentences);
  // Sorting is stable, so two identical scores keep their document order.
  const ranked = sentences.map((_, index) => index).sort((a, b) => scores[b] - scores[a]);
  const chosen = ranked.slice(0, maxSentences).sort((a, b) => a - b);
  return chosen.map((index) => sentences[index]).join(' ');
}

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 document ne quitte pas votre infrastructure

Point de rupture

Deux passages éloignés ne sont jamais reliés. Dans le document du test, une phrase dit que l’usine de Rouen fournit toutes les cellules de la ligne de Lyon ; dix phrases plus loin, une autre dit que Rouen ferme fin mars. La seconde est courte, tardive, et ses propres termes — fermer, fin mars — ne reparaissent nulle part ailleurs, tandis que la digression sur l’entrepôt occupe tout le vocabulaire du document : elle est la moins bien notée des onze et tombe la première, y compris quand on demande huit phrases sur onze. Et aucune phrase n’énonce ce qu’elles impliquent ensemble, donc aucune sélection, si large soit-elle, ne peut le rendre.

Quand passer au niveau suivant

Vous vous surprenez à retoucher à la main le bonus d’ouverture d’un type de document à l’autre, et vous avez sous la main des documents dont on peut cocher les bonnes phrases.

N1Notation supervisée des phrases sur cinq traits de surfacenégligeable · Instantané

Notation supervisée des phrases sur cinq traits de surface

Modèle classique légerN1négligeableInstantané

Python

snippets/summarise-a-long-document/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Noter les phrases avec un classifieur entraîné sur des traits de surface.

Niveau N1. Même forme extractive qu'en N0 — le résumé est toujours fait des
phrases du document — mais les poids sont appris au lieu d'être devinés.

N0 fixe à la main, une fois pour tous les documents du monde, l'arbitrage
entre la position et la densité des termes. Cette constante est une
supposition. Ici, des documents dont quelqu'un a coché les phrases de résumé
fixent plutôt les poids de cinq traits, et le modèle n'apprend rien que ces
cinq ne sachent exprimer. La position est codée 1 / (rang + 1) : le modèle
peut apprendre que les premières phrases comptent plus ou moins, jamais que
la dernière compte. Une phrase de conclusion ne gagne que par ce qu'elle
porte, un chiffre ou un mot-signal comme « overall ».

Les traits sont délibérément de surface. Ils décrivent où une phrase se
trouve et à quoi elle ressemble, pas ce qu'elle veut dire. C'est le plafond
de ce niveau, et la raison pour laquelle la fiche ne s'arrête pas là.
"""

import re
import unicodedata

import numpy as np
from sklearn.linear_model import LogisticRegression

# A full stop, question or exclamation mark followed by whitespace, or a line
# break: the same splitter as N0, with the same limits.
SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\s*\n\s*")
WORD = re.compile(r"[^\W_]+")

STOPWORDS = frozenset(
    "a an and are as at be been but by for from had has have in into is it its "
    "of on or that the their there they this to was were which will with".split()
)

# Words an author uses when about to state the point of what came before.
CUES = frozenset(
    "overall therefore total conclusion result summary altogether finally "
    "consequently".split()
)

# Sentences longer than this are already long; the feature saturates rather
# than letting one outlier stretch the scale for every other sentence.
LONG_SENTENCE = 25


def words(sentence: str) -> list[str]:
    """Lowercase words, composed first: a decomposed `é` would split the word."""
    return WORD.findall(unicodedata.normalize("NFC", sentence).lower())


def split_sentences(text: str) -> list[str]:
    """Cut the document into sentences, dropping empty ones."""
    parts = SENTENCE_END.split(text.strip())
    return [part.strip() for part in parts if part.strip()]


def sentence_features(sentences: list[str], index: int) -> list[float]:
    """
    Five things a reader notices before reading: where the sentence sits, how
    long it is, whether it carries a figure, whether it announces a conclusion,
    and how much of the opening it repeats.
    """
    sentence = sentences[index]
    found = words(sentence)
    opening = set(words(sentences[0]))
    content = [w for w in found if len(w) > 2 and w not in STOPWORDS]
    shared = sum(1 for word in content if word in opening)
    return [
        1.0 / (index + 1),
        min(len(found) / LONG_SENTENCE, 1.0),
        1.0 if any(c in "0123456789" for c in sentence) else 0.0,
        1.0 if any(word in CUES for word in found) else 0.0,
        shared / len(content) if content else 0.0,
    ]


def train(documents: list[list[str]], labels: list[list[int]]) -> LogisticRegression:
    """
    `documents` are lists of sentences; `labels[d][i]` is 1 when sentence `i`
    of document `d` belongs in the summary.

    The ticked sentences are the entire training set. Nothing here needs a GPU.
    """
    rows, targets = [], []
    for sentences, marks in zip(documents, labels):
        for index in range(len(sentences)):
            rows.append(sentence_features(sentences, index))
            targets.append(marks[index])
    return LogisticRegression(max_iter=2000).fit(np.array(rows), np.array(targets))


def summarise(model: LogisticRegression, text: str, max_sentences: int = 3) -> str:
    """Return the best-scored sentences, in the order the document puts them."""
    if max_sentences < 0:
        raise ValueError("max_sentences cannot be negative")
    sentences = split_sentences(text)
    if not sentences:
        return ""
    rows = np.array([sentence_features(sentences, i) for i in range(len(sentences))])
    scores = model.predict_proba(rows)[:, 1]
    # Sorting is stable, so two identical scores keep their document order.
    ranked = sorted(range(len(sentences)), key=lambda i: -scores[i])
    chosen = sorted(ranked[:max_sentences])
    return " ".join(sentences[index] for index in chosen)

JavaScript

snippets/summarise-a-long-document/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Noter les phrases avec un classifieur entraîné sur des traits de surface.
 *
 * Niveau N1. Même forme extractive qu'en N0 — le résumé est toujours fait des
 * phrases du document — mais les poids sont appris au lieu d'être devinés.
 *
 * N0 fixe à la main, une fois pour tous les documents du monde, l'arbitrage
 * entre la position et la densité des termes. Cette constante est une
 * supposition. Ici, des documents dont quelqu'un a coché les phrases de résumé
 * fixent plutôt les poids de cinq traits, et le modèle n'apprend rien que ces
 * cinq ne sachent exprimer. La position est codée 1 / (rang + 1) : le modèle
 * peut apprendre que les premières phrases comptent plus ou moins, jamais que
 * la dernière compte. Une phrase de conclusion ne gagne que par ce qu'elle
 * porte, un chiffre ou un mot-signal comme « overall ».
 *
 * Les traits sont délibérément de surface. Ils décrivent où une phrase se
 * trouve et à quoi elle ressemble, pas ce qu'elle veut dire. C'est le plafond
 * de ce niveau, et la raison pour laquelle la fiche ne s'arrête pas là.
 *
 * La régression logistique est écrite en entier plutôt que tirée d'une
 * bibliothèque, si bien que l'extrait n'a besoin que de Node. La version
 * Python de cet extrait appelle scikit-learn ; l'objectif minimisé
 * ci-dessous est le même, si bien que les deux classent les phrases d'un
 * document de la même façon.
 */

// A full stop, question or exclamation mark followed by whitespace, or a line
// break: the same splitter as N0, with the same limits.
const SENTENCE_END = /(?<=[.!?])\s+|\s*\n\s*/;
const WORD = /[\p{L}\p{N}]+/gu;

/** Lowercase words, composed first: a decomposed `é` would split the word. */
function words(sentence) {
  return sentence.normalize('NFC').toLowerCase().match(WORD) ?? [];
}

const STOPWORDS = new Set(
  ('a an and are as at be been but by for from had has have in into is it its ' +
    'of on or that the their there they this to was were which will with').split(' '),
);

// Words an author uses when about to state the point of what came before.
const CUES = new Set(
  ('overall therefore total conclusion result summary altogether finally ' +
    'consequently').split(' '),
);

// Sentences longer than this are already long; the feature saturates rather
// than letting one outlier stretch the scale for every other sentence.
const LONG_SENTENCE = 25;

/** Cut the document into sentences, dropping empty ones. */
export function splitSentences(text) {
  return text
    .trim()
    .split(SENTENCE_END)
    .map((part) => part.trim())
    .filter(Boolean);
}

/**
 * Five things a reader notices before reading: where the sentence sits, how
 * long it is, whether it carries a figure, whether it announces a conclusion,
 * and how much of the opening it repeats.
 */
export function sentenceFeatures(sentences, index) {
  const sentence = sentences[index];
  const found = words(sentence);
  const opening = new Set(words(sentences[0]));
  const content = found.filter((w) => w.length > 2 && !STOPWORDS.has(w));
  const shared = content.filter((word) => opening.has(word)).length;
  return [
    1 / (index + 1),
    Math.min(found.length / LONG_SENTENCE, 1),
    /[0-9]/.test(sentence) ? 1 : 0,
    found.some((word) => CUES.has(word)) ? 1 : 0,
    content.length ? shared / content.length : 0,
  ];
}

/**
 * `documents` are lists of sentences; `labels[d][i]` is 1 when sentence `i` of
 * document `d` belongs in the summary.
 *
 * Full-batch gradient descent on the mean log-loss, plus the same L2 penalty
 * scikit-learn applies by default. The bias is left unpenalised, as it is
 * there too. The objective is strictly convex, so enough steps land on the one
 * optimum whichever language walks towards it.
 */
export function train(documents, labels, { epochs = 4000, rate = 1, strength = 1 } = {}) {
  const rows = [];
  const targets = [];
  for (const [d, sentences] of documents.entries()) {
    for (let i = 0; i < sentences.length; i += 1) {
      rows.push(sentenceFeatures(sentences, i));
      targets.push(labels[d][i]);
    }
  }
  const width = rows[0].length;
  const weights = new Array(width).fill(0);
  let bias = 0;
  const penalty = 1 / (strength * rows.length);

  for (let epoch = 0; epoch < epochs; epoch += 1) {
    const gradient = new Array(width).fill(0);
    let biasGradient = 0;
    for (let i = 0; i < rows.length; i += 1) {
      let z = bias;
      for (let j = 0; j < width; j += 1) z += weights[j] * rows[i][j];
      const error = 1 / (1 + Math.exp(-z)) - targets[i];
      for (let j = 0; j < width; j += 1) gradient[j] += (error * rows[i][j]) / rows.length;
      biasGradient += error / rows.length;
    }
    for (let j = 0; j < width; j += 1) weights[j] -= rate * (gradient[j] + penalty * weights[j]);
    bias -= rate * biasGradient;
  }
  return { weights, bias };
}

/** Probability that a sentence belongs in the summary. */
function score(model, features) {
  let z = model.bias;
  for (let j = 0; j < features.length; j += 1) z += model.weights[j] * features[j];
  return 1 / (1 + Math.exp(-z));
}

/** Return the best-scored sentences, in the order the document puts them. */
export function summarise(model, text, maxSentences = 3) {
  if (maxSentences < 0) throw new RangeError('maxSentences cannot be negative');
  const sentences = splitSentences(text);
  if (sentences.length === 0) return '';
  const scores = sentences.map((_, i) => score(model, sentenceFeatures(sentences, i)));
  // Sorting is stable, so two identical scores keep their document order.
  const ranked = sentences.map((_, index) => index).sort((a, b) => scores[b] - scores[a]);
  const chosen = ranked.slice(0, maxSentences).sort((a, b) => a - b);
  return chosen.map((index) => sentences[index]).join(' ');
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

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

  • Traitement du document sur votre infrastructure
  • Le corpus d’entraînement est fait de documents réels dont quelqu’un a coché les phrases : il entre dans votre registre des traitements si vous en tenez un

Point de rupture

Les traits décrivent l’aspect d’une phrase, pas ce qu’elle dit. Dans le test, une phrase placée tôt, portant un chiffre, ouverte par « Overall » et reprenant les mots de la première ligne est retenue alors qu’elle annonce une commande de porte-blocs, pendant que la vraie trouvaille de l’audit — un rayon mal compté depuis le printemps — n’a aucun de ces signes et reste dehors ; étiqueter davantage n’y change rien, puisque le sens n’est jamais montré au modèle. Et ce niveau reste extractif : sur le document à deux bouts il retrouve bien les deux prémisses, et n’énonce toujours pas la conclusion, parce que l’énoncer voudrait dire écrire une phrase que personne n’a écrite.

Quand passer au niveau suivant

Le résumé doit énoncer ce que le document démontre, et vous constatez qu’aucune phrase du document ne l’énonce.

N2Modèle de résumé abstractif auto-hébergé (anglais), en autant de passes qu’il fautmodéré · Plus d’une seconde

Modèle de résumé abstractif auto-hébergé (anglais), en autant de passes qu’il faut

Petit modèle spécialisé auto-hébergéN2modéréPlus d’une seconde

Python

snippets/summarise-a-long-document/n2.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.
"""
Résumer un document long avec un modèle abstractif auto-hébergé.

Niveau N2. Le premier niveau qui écrive. N0 et N1 choisissent des phrases et
ne peuvent jamais rendre que ce que l'auteur a déjà écrit ; celui-ci produit
des phrases qui n'étaient pas dans le document.

Ce gain a un prix, et le prix est dans ce fichier plutôt que dans le modèle.
Un modèle de séquence à séquence lit une fenêtre de taille fixe. Un document
long n'y entre pas : il faut donc le découper aux frontières de phrase, le
résumer morceau par morceau, puis résumer les notes, en autant de passes
qu'il en faut pour tenir dans une seule fenêtre. Chacun de ces appels
peut échouer ou revenir vide, et un résumé qui ne porte silencieusement que
sur la moitié d'un document est pire que pas de résumé du tout.

Ce que ce fichier ne sait pas faire, à aucun prix : vérifier que ce que le
modèle a écrit est ce que le document disait. Rien dans cette plomberie ne le
peut. Voyez le test.

Il ne vérifie pas non plus la langue. Le point de contrôle nommé plus bas
déclare « language: en » sur sa fiche et a été affiné sur des dépêches
anglophones ; sur une note française, il écrira quelque chose, et rien ici ne
l'en empêchera. Il existe un modèle de résumé multilingue — mT5 affiné sur
XL-Sum, quarante-cinq langues dont le français — sous licence CC BY-SA-NC
4.0, qui exclut l'usage commercial. Lisez la fiche du modèle avant de
choisir, et lisez-y la langue d'abord.
"""

from __future__ import annotations

import functools
import re

# A full stop, question or exclamation mark followed by whitespace, or a line
# break: a transcript without final punctuation is still cut into lines.
SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\s*\n\s*")

# The same weights in both languages: JavaScript loads their ONNX conversion.
# English only: its card says `language: en`, and it is distilled from a model
# fine-tuned on CNN/DailyMail and XSum, which are English newswire.
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"

# What one call to the model is given to read. The window is 1,024 tokens, and
# characters are not tokens: with this model's tokenizer, 3,000 characters of
# French prose come to 1,091 tokens, 2,000 to 727. A piece that still
# overflows, code or a table, is refused by the model below, not truncated.
CHUNK_CHARACTERS = 2000

# What the whole function is allowed to read. A self-hosted model costs
# machine time rather than money, but a document nobody meant to send is still
# better refused than churned through in silence.
MAX_CHARACTERS = 200_000


class SummaryUnavailable(Exception):
    """The model failed, or returned nothing usable."""


class LocalSummariser:
    """The real model, loaded from local weights and run on this machine."""

    def __init__(self, name: str = MODEL_NAME) -> None:  # pragma: no cover - loads weights
        # transformers 5 removed the "summarization" pipeline: call the model itself.
        from transformers import AutoModelForSeq2SeqLM, AutoTokenizer

        self._tokenizer = AutoTokenizer.from_pretrained(name)
        self._model = AutoModelForSeq2SeqLM.from_pretrained(name)
        # The generation settings the checkpoint was published with.
        self._settings = self._model.config.task_specific_params["summarization"]

    def generate(self, text: str) -> str:  # pragma: no cover - loads weights
        inputs = self._tokenizer(text, return_tensors="pt")
        # Refused rather than truncated: a summary of the start of a piece
        # reads exactly like a summary of all of it.
        if inputs["input_ids"].shape[1] > self._tokenizer.model_max_length:
            raise ValueError("this piece is longer than the model's window")
        output = self._model.generate(**inputs, **self._settings)
        return self._tokenizer.decode(output[0], skip_special_tokens=True)


@functools.cache
def default_model() -> LocalSummariser:  # pragma: no cover - loads weights
    """Loaded once and kept for the life of the process: the loading is the slow part."""
    return LocalSummariser()


def pack(units: list[str], size: int = CHUNK_CHARACTERS) -> list[str]:
    """Join consecutive units into pieces of at most `size` characters."""
    pieces: list[str] = []
    current = ""
    for unit in units:
        if current and len(current) + 1 + len(unit) > size:
            pieces.append(current)
            current = unit
        else:
            current = f"{current} {unit}".strip()
    if current:
        pieces.append(current)
    return pieces


def chunk(text: str, size: int = CHUNK_CHARACTERS) -> list[str]:
    """
    Cut the document into pieces that fit the model's window, at sentence
    boundaries.

    A sentence longer than the window on its own is passed whole. Cutting
    mid-sentence would hand the model half a clause, which is a worse thing to
    summarise.
    """
    sentences = (part.strip() for part in SENTENCE_END.split(text.strip()))
    return pack([sentence for sentence in sentences if sentence], size)


def summarise(text: str, model=None, *, attempts: int = 2) -> str:
    """
    `model` is injected so this function can be tested without loading weights.
    In production it defaults to the real model.
    """
    if len(text) > MAX_CHARACTERS:
        raise ValueError(f"document longer than {MAX_CHARACTERS} characters")
    pieces = chunk(text)
    if not pieces:
        return ""
    model = model or default_model()

    while True:
        notes = [_generate(model, piece, attempts) for piece in pieces]
        if len(notes) == 1:
            return notes[0]
        # Next pass: the model reads back its own notes, packed to the window
        # like the document was. It never reads the document again.
        packed = pack(notes)
        if len(packed) >= len(pieces):
            raise SummaryUnavailable("the notes are no shorter than what they summarise")
        pieces = packed


def _generate(model, text: str, attempts: int) -> str:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            written = (model.generate(text) or "").strip()
            if written:
                return written
            # An empty answer is a failure, not a summary. Returning it would
            # leave a hole in the middle of the document with no trace.
            last_error = ValueError("the model returned an empty summary")
        except Exception as error:  # noqa: BLE001 - any model failure is retried
            last_error = error
    raise SummaryUnavailable(str(last_error))

JavaScript

snippets/summarise-a-long-document/n2.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.
/**
 * Résumer un document long avec un modèle abstractif auto-hébergé.
 *
 * Niveau N2. Le premier niveau qui écrive. N0 et N1 choisissent des phrases et
 * ne peuvent jamais rendre que ce que l'auteur a déjà écrit ; celui-ci produit
 * des phrases qui n'étaient pas dans le document.
 *
 * Ce gain a un prix, et le prix est dans ce fichier plutôt que dans le modèle.
 * Un modèle de séquence à séquence lit une fenêtre de taille fixe. Un document
 * long n'y entre pas : il faut donc le découper aux frontières de phrase, le
 * résumer morceau par morceau, puis résumer les notes, en autant de passes
 * qu'il en faut pour tenir dans une seule fenêtre. Chacun de ces appels
 * peut échouer ou revenir vide, et un résumé qui ne porte silencieusement que
 * sur la moitié d'un document est pire que pas de résumé du tout.
 *
 * Ce que ce fichier ne sait pas faire, à aucun prix : vérifier que ce que le
 * modèle a écrit est ce que le document disait. Rien dans cette plomberie ne le
 * peut. Voyez le test.
 *
 * Il ne vérifie pas non plus la langue. Le point de contrôle nommé plus bas
 * déclare « language: en » sur sa fiche et a été affiné sur des dépêches
 * anglophones ; sur une note française, il écrira quelque chose, et rien ici ne
 * l'en empêchera. Il existe un modèle de résumé multilingue — mT5 affiné sur
 * XL-Sum, quarante-cinq langues dont le français — sous licence CC BY-SA-NC
 * 4.0, qui exclut l'usage commercial. Lisez la fiche du modèle avant de
 * choisir, et lisez-y la langue d'abord.
 */

// A full stop, question or exclamation mark followed by whitespace, or a line
// break: a transcript without final punctuation is still cut into lines.
const SENTENCE_END = /(?<=[.!?])\s+|\s*\n\s*/;

// The ONNX conversion of sshleifer/distilbart-cnn-12-6, the weights Python
// loads. English only: that card says `language: en`, and the model is
// distilled from one fine-tuned on CNN/DailyMail and XSum, English newswire.
export const MODEL_NAME = 'Xenova/distilbart-cnn-12-6';

// What one call to the model is given to read. The window is 1,024 tokens, and
// characters are not tokens: with this model's tokenizer, 3,000 characters of
// French prose come to 1,091 tokens, 2,000 to 727. A piece that still
// overflows, code or a table, is refused by the model below, not truncated.
export const CHUNK_CHARACTERS = 2000;

// What the whole function is allowed to read. A self-hosted model costs
// machine time rather than money, but a document nobody meant to send is still
// better refused than churned through in silence.
export const MAX_CHARACTERS = 200_000;

export class SummaryUnavailable extends Error {}

/** The real model, loaded from local weights and run on this machine. */
export class LocalSummariser {
  #pipeline;

  async load(name = MODEL_NAME) {
    // Loads weights, so this is never reached in the tests.
    const { pipeline } = await import('@huggingface/transformers');
    this.#pipeline = await pipeline('summarization', name);
    return this;
  }

  async generate(text) {
    const { tokenizer } = this.#pipeline;
    // Refused rather than truncated: a summary of the start of a piece reads
    // exactly like a summary of all of it.
    if (tokenizer.encode(text).length > tokenizer.model_max_length) {
      throw new RangeError("this piece is longer than the model's window");
    }
    const [{ summary_text: written }] = await this.#pipeline(text);
    return written;
  }
}

let loading; // the default model, loaded once for the life of the process

function defaultModel() {
  loading ??= new LocalSummariser().load().catch((error) => {
    loading = undefined; // a failed load is tried again next time, not kept
    throw error;
  });
  return loading;
}

/** Join consecutive units into pieces of at most `size` characters. */
export function pack(units, size = CHUNK_CHARACTERS) {
  const pieces = [];
  let current = '';
  for (const unit of units) {
    if (current && current.length + 1 + unit.length > size) {
      pieces.push(current);
      current = unit;
    } else {
      current = `${current} ${unit}`.trim();
    }
  }
  if (current) pieces.push(current);
  return pieces;
}

/**
 * Cut the document into pieces that fit the model's window, at sentence
 * boundaries.
 *
 * A sentence longer than the window on its own is passed whole. Cutting
 * mid-sentence would hand the model half a clause, which is a worse thing to
 * summarise.
 */
export function chunk(text, size = CHUNK_CHARACTERS) {
  const sentences = text.trim().split(SENTENCE_END).map((part) => part.trim());
  return pack(sentences.filter(Boolean), size);
}

/**
 * @param {string} text
 * @param {object} options
 * @param {{generate: Function}} [options.model] injected so this can be tested
 *   without loading weights; defaults to the real model
 * @param {number} [options.attempts]
 */
export async function summarise(text, { model, attempts = 2 } = {}) {
  if (text.length > MAX_CHARACTERS) {
    throw new RangeError(`document longer than ${MAX_CHARACTERS} characters`);
  }
  let pieces = chunk(text);
  if (pieces.length === 0) return '';
  model ??= await defaultModel();

  for (;;) {
    const notes = [];
    for (const piece of pieces) notes.push(await generate(model, piece, attempts));
    if (notes.length === 1) return notes[0];
    // Next pass: the model reads back its own notes, packed to the window like
    // the document was. It never reads the document again.
    const packed = pack(notes);
    if (packed.length >= pieces.length) {
      throw new SummaryUnavailable('the notes are no shorter than what they summarise');
    }
    pieces = packed;
  }
}

async function generate(model, text, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const written = ((await model.generate(text)) ?? '').trim();
      if (written) return written;
      // An empty answer is a failure, not a summary. Returning it would leave
      // a hole in the middle of the document with no trace.
      lastError = new Error('the model returned an empty summary');
    } catch (error) {
      lastError = error;
    }
  }
  throw new SummaryUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Difficile à tester
  • Dépend d’une bibliothèque
  • Empreinte modérée

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

  • Traitement du document sur votre infrastructure, y compris les données personnelles et les secrets d’affaires qu’il contient
  • La licence des poids du modèle encadre son usage, usage commercial compris : celle du point de contrôle nommé est Apache 2.0, celle du seul équivalent multilingue courant interdit l’usage commercial

Point de rupture

Le point de contrôle nommé ne résume que l’anglais : sa fiche déclare `language: en`, et il est distillé d’un modèle affiné sur CNN/DailyMail et XSum, des dépêches anglophones. Rien dans l’extrait ne le refuse : un document français y entre, et une réponse en sort — le test le montre. Ensuite seulement vient l’autre limite. Le modèle écrit, donc il peut écrire ce que le document ne dit pas. Le test le montre sur le double local, à qui il fait rendre tour à tour deux résumés du même document : « la ligne de Lyon s’arrêtera quand Rouen fermera fin mars », qui en découle, et « la ligne de Lyon déménagera à Rouen en avril », qui n’y figure nulle part. La fonction rend les deux à l’identique, sans un avertissement, parce que la seule chose qu’elle sache vérifier d’une réponse est qu’elle n’est pas vide. Ce que le test établit, c’est donc ce que cette plomberie laisse passer, et non ce qu’un vrai modèle écrirait. La découpe s’y ajoute : dès qu’un document dépasse la fenêtre du modèle, les passes suivantes résument les notes que le modèle a écrites, et plus jamais le document.

Quand passer au niveau suivant

Vous voulez une autre forme de sortie — trois phrases, ou une liste de points — et vous découvrez qu’il faudrait réaffiner le modèle pour l’obtenir.

Pour aller plus loin

Source des chiffres

Métadonnées