Summarise a long document

Get, in a few sentences, what a document too long to read says.

Yes, a general-purpose model earns its place

élevéOver a secondYour data goes to a third party

The verdict

N3, because summarising means rewriting, and this is the only level that writes a sentence nobody wrote without costing you an inference service to run or a chunking scheme over several passes — the example model's window is 1,047,576 tokens, so a fifty-page report goes in one call and whole — and whose output shape is asked for in the prompt instead of being whatever the model was trained to write. For a French reader that also settles N2: the checkpoint it loads only summarises English, and the one common multilingual equivalent, mT5 fine-tuned on XL-Sum, carries a licence that forbids commercial use — until the model changes, N2 is not an option on a French document. The price deserves saying out loud: the document goes to a third party, every call is billed, two calls on the same document are not guaranteed to return the same summary, and your tests can check the shape and nothing else — the one on this entry lets through a flawless summary that invents a board of directors. If your document cannot carry that risk, the answer is not a better prompt: it is a person reading the source next to the summary, or N0, which cannot invent anything because all it can do is quote, at the price of never stating what the document demonstrates.

Summary through a general-purpose model, answering in JSON

Yes, a general-purpose model earns its place

General-purpose LLM APIN3élevéOver a second

Python

snippets/summarise-a-long-document/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Summarise a long document by asking a general-purpose model.

Rung N3. It is here so you can see what it costs, not because this entry
recommends it.

What it buys over N2 is real: no weights to host, no machine to keep warm, and
an output shape set in the prompt — three sentences, a list of points, or both
— instead of trained into the weights. Whether the answer respects that shape
is checked below, not assumed.

What it costs is in this file. Decide what one call may cost, because the
provider charges by the token and a document nobody meant to send is money
gone. Retry, because the call goes over a network. Parse an answer that is
only probably the JSON you asked for. Refuse an answer of the wrong shape
rather than passing half of one to the caller. That plumbing is what your
tests can cover.

What no test here can cover: whether the summary is true of the document.
Nothing below can tell a fluent sentence the document never supported from a
good one. See the 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.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Summarise a long document by asking a general-purpose model.
 *
 * Rung N3. It is here so you can see what it costs, not because this entry
 * recommends it.
 *
 * What it buys over N2 is real: no weights to host, no machine to keep warm,
 * and an output shape set in the prompt — three sentences, a list of points,
 * or both — instead of trained into the weights. Whether the answer respects
 * that shape is checked below, not assumed.
 *
 * What it costs is in this file. Decide what one call may cost, because the
 * provider charges by the token and a document nobody meant to send is money
 * gone. Retry, because the call goes over a network. Parse an answer that is
 * only probably the JSON you asked for. Refuse an answer of the wrong shape
 * rather than passing half of one to the caller. That plumbing is what your
 * tests can cover.
 *
 * What no test here can cover: whether the summary is true of the document.
 * Nothing below can tell a fluent sentence the document never supported from
 * a good one. See the 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 };
}

Try it

This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.

The model’s answer is simulated by the local double the tests use. What is computed here is what the code sends, what it refuses, what it retries and what it accepts.

  1. A three-sentence report

    Given

    The support team migrated the ticketing system to a new platform in March. Every agent was trained during the two weeks before the switch. The old platform stayed available in read-only mode for a month afterwards.

    What it returns

    The ticketing system moved to a new platform in March. — agents trained beforehand — old platform kept read-only

    1 call sent, 214 characters of document left for the provider.

  2. An empty document

    Given

    (nothing, or whitespace)

    What it returns

    No call sent, so nothing spent.

  3. A document over the ceiling that was set

    Given

    a 2,001-character document, for a ceiling set at 2,000

    What it returns

    Refused before the first call

    document longer than 2000 characters

    Nothing left, nothing owed.

  4. The provider fails twice

    Given

    the same report, but the first two calls fail

    What it returns

    The ticketing system moved to a new platform in March. — agents trained beforehand — old platform kept read-only

    3 calls sent: the first two failed, the third answered.

  5. The model answers in prose

    Given

    the same report; the model answers “Sure! Here is a summary…”

    What it returns

    Answer rejected

    The model answered in prose where the code asked for JSON. The code refuses rather than returning an empty summary.

    3 calls sent and billed, no usable answer.

  6. The model summarises what the document never said

    Given

    the same report, which mentions no lost tickets

    What it returns

    The migration lost three days of tickets. — migration incident

    1 call sent, 214 characters of document left for the provider.

    Here it breaksThe code returns this summary without flinching: it checks the shape of the answer, never its truth. No test on this entry can catch a fluent, false sentence.

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • High footprint

What this adds to your regulatory scope

  • Transfer of the document to a processor, with the contractual framing that implies
  • Whatever third-party personal data and trade secrets the document contains leave with it
  • Processing location and request retention to be confirmed with the provider
  • Does not excuse you from your own duties of transparency and minimisation

Breaking point

The instruction « use only what the document says » is a request, not a constraint. The test makes the local double return valid JSON, the right shape, the right length, fluent, announcing a cut in ticket handling time along with a second phase approved by the board: neither the board nor that gain appears in the document. Every check in the snippet passes, because every check is about shape; whether a summary follows from its source is a question about meaning, and no parser settles it. What is demonstrated there is that nothing in the snippet stops an invention, not that a provider produces one on this document.

When to move up a level

There is no level above this one.

The four options

  1. N0Extractive summary: term density and a position bonusFree · Instant
  2. N1Supervised sentence scoring on five surface featuresnégligeable · Instant
  3. N2Self-hosted abstractive summariser (English), in as many passes as it takesmodéré · Over a second
  4. RecommendedN3Summary through a general-purpose model, answering in JSONélevé · Over a second

The other options

N0Extractive summary: term density and a position bonusFree · Instant

Extractive summary: term density and a position bonus

Rule and classic algorithmN0FreeInstant

Python

snippets/summarise-a-long-document/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Summarise a long document by choosing its own best sentences.

Rung N0. Extractive: every sentence of the summary appears verbatim in the
source, because this code never writes a word, it only selects.

Two classical signals, and nothing else.

Term frequency. A word the document keeps coming back to is what the document
is about, so a sentence dense in such words carries more of the subject than a
sentence made of connectives. Dividing by the length of the sentence measures
density rather than volume, which stops a long sentence from winning by size.

Position. An author states the subject early. A small bonus that decays with
the rank of the sentence encodes that habit, without handing the summary to
the opening paragraph outright.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Summarise a long document by choosing its own best sentences.
 *
 * Rung N0. Extractive: every sentence of the summary appears verbatim in the
 * source, because this code never writes a word, it only selects.
 *
 * Two classical signals, and nothing else.
 *
 * Term frequency. A word the document keeps coming back to is what the
 * document is about, so a sentence dense in such words carries more of the
 * subject than a sentence made of connectives. Dividing by the length of the
 * sentence measures density rather than volume, which stops a long sentence
 * from winning by size.
 *
 * Position. An author states the subject early. A small bonus that decays
 * with the rank of the sentence encodes that habit, without handing the
 * summary to the opening paragraph outright.
 */

// 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(' ');
}

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the document never leaves your infrastructure

Breaking point

Two distant passages are never joined. In the test document, one sentence says the Rouen plant supplies every cell used on the Lyon assembly line; ten sentences later, another says Rouen closes at the end of March. The second is short, late, and its own terms — closing, end of March — turn up nowhere else, while the digression about the warehouse takes up the whole vocabulary of the document: it scores lowest of the eleven and is dropped first, even when eight of the eleven are asked for. And no sentence states what the two imply together, so no selection, however wide, can return it.

When to move up a level

You catch yourself retuning the opening bonus by hand from one kind of document to the next, and you have documents whose right sentences someone can tick off.

N1Supervised sentence scoring on five surface featuresnégligeable · Instant

Supervised sentence scoring on five surface features

Lightweight classic modelN1négligeableInstant

Python

snippets/summarise-a-long-document/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Score sentences with a classifier trained on surface features.

Rung N1. Same extractive shape as N0 — the summary is still made of the
document's own sentences — but the weights are learnt instead of guessed.

N0 fixes the trade between position and term density by hand, once, for every
document in the world. That constant is a guess. Here, documents whose summary
sentences someone has ticked off set the weights of five features instead, and
the model learns nothing those five cannot express. Position is coded as
1 / (rank + 1): the model can learn that early sentences count for more or for
less, never that the last one counts. A closing sentence gains only through
what it carries, a figure or a cue word such as `overall`.

The features are deliberately surface-level. They describe where a sentence
sits and what it looks like, not what it means. That is the ceiling of this
rung, and the reason the entry does not stop here.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Score sentences with a classifier trained on surface features.
 *
 * Rung N1. Same extractive shape as N0 — the summary is still made of the
 * document's own sentences — but the weights are learnt instead of guessed.
 *
 * N0 fixes the trade between position and term density by hand, once, for
 * every document in the world. That constant is a guess. Here, documents whose
 * summary sentences someone has ticked off set the weights of five features
 * instead, and the model learns nothing those five cannot express. Position is
 * coded as 1 / (rank + 1): the model can learn that early sentences count for
 * more or for less, never that the last one counts. A closing sentence gains
 * only through what it carries, a figure or a cue word such as `overall`.
 *
 * The features are deliberately surface-level. They describe where a sentence
 * sits and what it looks like, not what it means. That is the ceiling of this
 * rung, and the reason the entry does not stop here.
 *
 * Logistic regression is written out rather than pulled from a package, so
 * the snippet needs nothing but Node. The Python version of this
 * snippet calls scikit-learn; the objective minimised below is the same one,
 * so both rank a document's sentences alike.
 */

// 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(' ');
}

What this means for you

  • Your data stays put
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • Low footprint

What this adds to your regulatory scope

  • Processing of the document on your own infrastructure
  • The training corpus is made of real documents whose sentences someone has ticked off: it belongs in your record of processing activities, where you keep one

Breaking point

The features describe what a sentence looks like, not what it says. In the test, a sentence that sits early, carries a figure, opens with « Overall » and echoes the words of the first line is kept although what it announces is an order of clipboards, while the audit’s real finding — an aisle miscounted since the spring — carries none of those markers and is left out; more labelling changes nothing, since meaning is never shown to the model. And this level is still extractive: on the two-ended document it does retrieve both premises, and still does not state the conclusion, because stating it would mean writing a sentence nobody wrote.

When to move up a level

The summary has to state what the document demonstrates, and you find that no sentence of the document states it.

N2Self-hosted abstractive summariser (English), in as many passes as it takesmodéré · Over a second

Self-hosted abstractive summariser (English), in as many passes as it takes

Small self-hosted specialised modelN2modéréOver a second

Python

snippets/summarise-a-long-document/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Summarise a long document with a self-hosted abstractive model.

Rung N2. The first rung that writes. N0 and N1 choose sentences and can only
ever return what the author already wrote; this one produces sentences that
were not in the document.

That gain has a price, and the price is in this file rather than in the model.
A sequence-to-sequence summariser reads a fixed-size window. A long document
does not fit, so it has to be cut at sentence boundaries, summarised piece by
piece, and the notes summarised again, in as many passes as it takes to fit
one window. Every one of those calls can fail or come back empty, and a
summary that is silently half a document is worse than no summary at all.

What this file cannot do, at any price: check that what the model wrote is
what the document said. Nothing in this plumbing can. See the test.

Nor does it check the language. The checkpoint named below declares
`language: en` on its card and was fine-tuned on English newswire; on a French
note it will write something, and nothing here will stop it. A multilingual
summariser exists — mT5 fine-tuned on XL-Sum, forty-five languages, French
among them — under CC BY-SA-NC 4.0, which rules out commercial use. Read the
card before you pick, and read it for the language first.
"""

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.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Summarise a long document with a self-hosted abstractive model.
 *
 * Rung N2. The first rung that writes. N0 and N1 choose sentences and can only
 * ever return what the author already wrote; this one produces sentences that
 * were not in the document.
 *
 * That gain has a price, and the price is in this file rather than in the
 * model. A sequence-to-sequence summariser reads a fixed-size window. A long
 * document does not fit, so it has to be cut at sentence boundaries,
 * summarised piece by piece, and the notes summarised again, in as many passes
 * as it takes to fit one window. Every one of those calls can fail or come
 * back empty, and a summary that is silently half a document is worse than no
 * summary at all.
 *
 * What this file cannot do, at any price: check that what the model wrote is
 * what the document said. Nothing in this plumbing can. See the test.
 *
 * Nor does it check the language. The checkpoint named below declares
 * `language: en` on its card and was fine-tuned on English newswire; on a
 * French note it will write something, and nothing here will stop it. A
 * multilingual summariser exists — mT5 fine-tuned on XL-Sum, forty-five
 * languages, French among them — under CC BY-SA-NC 4.0, which rules out
 * commercial use. Read the card before you pick, and read it for the language
 * first.
 */

// 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));
}

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Hard to test
  • Depends on a library
  • Moderate footprint

What this adds to your regulatory scope

  • Processing of the document on your own infrastructure, personal data and trade secrets included
  • The licence attached to the model weights governs their use, commercial use included: the checkpoint named here is Apache 2.0, while the one common multilingual equivalent forbids commercial use

Breaking point

The checkpoint named here only summarises English: its card declares `language: en`, and it is distilled from a model fine-tuned on CNN/DailyMail and XSum, English newswire. Nothing in the snippet refuses that: a French document goes in, and an answer comes out — the test shows it. Only then does the other limit come. The model writes, so it can write what the document does not say. The test shows it on the local double, which it makes return, in turn, two summaries of the same document: « the Lyon assembly line will stop when Rouen closes at the end of March », which follows from it, and « the Lyon assembly line will move to Rouen in April », which appears nowhere in it. The function returns both identically and without a warning, because the only thing it can check about an answer is that it is not empty. What the test establishes is therefore what this plumbing lets through, not what a real model would write. Chunking adds to that: as soon as a document overflows the model’s window, the later passes summarise the notes the model itself wrote, and never the document again.

When to move up a level

You want a different shape of answer — three sentences, or a list of points — and you find that getting it would mean fine-tuning the model again.

Further reading

Where the figures come from

Metadata