Catalogue Extraire

Découper une adresse en champs

Découper une adresse saisie sur une ligne en numéro, rue, complément, code postal et ville.

RecommandéN1 Révisée le

Récapitulatif des barreaux
Barreau Approche Coût Latence Données Déterministe Verdict
Règle et algorithme classique N0 — Règle et algorithme classique Ancrage sur le code postal et dictionnaire de types de voie Nul <1 ms Rien ne sort Oui
Modèle classique léger N1 — Modèle classique léger Étiquetage des jetons par régression logistique sur le contexte Négligeable ~10 ms Reste dans votre infrastructure Oui Recommandé
Petit modèle spécialisé auto-hébergé N2 — Petit modèle spécialisé auto-hébergé Analyseur d'adresses statistique auto-hébergé (libpostal) Faible ~10 ms Reste dans votre infrastructure Oui
API de LLM généraliste N3 — API de LLM généraliste Découpage structuré par appel à un modèle généraliste Élevé ~1 s Part chez un tiers Non

N0 — Règle et algorithme classique Règle et algorithme classique

Ancrage sur le code postal et dictionnaire de types de voie

Coût
Nul
Latence
<1 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/parse-address-into-fields/n0.py
"""
Parse a postal address into fields: regular expressions anchored on the
postcode.

Rung N0. Deterministic, standard library only, and short enough to read in one
sitting.

Two things make this work.

First, the postcode is the anchor. Five digits in a row cut a French address in
two: what comes before is the street line, what comes after is the town. Trying
to recognise the town by its name would mean shipping a list of communes and
keeping it up to date.

Second, the street type is read from a dictionary rather than guessed, so the
abbreviations people actually type — « av. », « bd », « imp. » — come out as one
canonical spelling.
"""

import re
import unicodedata

# The street types of a French address, with the abbreviations people type.
# This is trade knowledge, not example data, so it belongs in the snippet.
STREET_TYPES = {
    "r": "rue", "rue": "rue",
    "av": "avenue", "ave": "avenue", "avenue": "avenue",
    "bd": "boulevard", "bld": "boulevard", "boul": "boulevard", "boulevard": "boulevard",
    "imp": "impasse", "impasse": "impasse",
    "all": "allée", "allee": "allée",
    "ch": "chemin", "chemin": "chemin",
    "pl": "place", "place": "place",
    "rte": "route", "route": "route",
    "quai": "quai", "cours": "cours", "crs": "cours",
    "sq": "square", "square": "square",
    "voie": "voie", "passage": "passage", "sentier": "sentier",
    "chaussee": "chaussée", "fbg": "faubourg", "faubourg": "faubourg",
    "cite": "cité", "villa": "villa", "esplanade": "esplanade",
}

FIELDS = ("number", "street_type", "street", "postcode", "city")

# A house number, and the repetition index that may follow it: 8, 8 bis, 12B.
HOUSE_NUMBER = re.compile(r"^(\d{1,4})\s*(bis|ter|quater|[a-z])?\b", re.IGNORECASE)

# A French postcode: five digits standing alone.
POSTCODE = re.compile(r"\b\d{5}\b")


def normalise(text: str) -> str:
    """Reduce commas, line breaks and exotic spaces to a single plain space."""
    text = unicodedata.normalize("NFKC", text).replace(",", " ")
    return re.sub(r"\s+", " ", text).strip()


def fold(word: str) -> str:
    """Lowercase, drop the accents and the trailing dot, for lookup only."""
    decomposed = unicodedata.normalize("NFD", word.lower())
    return "".join(c for c in decomposed if not unicodedata.combining(c)).strip(".")


def parse(address: str) -> dict:
    """
    Split an address into number, street type, street, postcode and town.

    Every field is a string, empty when the address does not carry it. Returning
    an empty string rather than nothing at all keeps the caller from having to
    test each field before printing it.
    """
    fields = dict.fromkeys(FIELDS, "")
    text = normalise(address)

    # The anchor. Take the last postcode: a street name may carry a year, and a
    # town never comes before its postcode in the French convention.
    postcodes = list(POSTCODE.finditer(text))
    if postcodes:
        found = postcodes[-1]
        fields["postcode"] = found.group()
        fields["city"] = text[found.end():].strip()
        text = text[: found.start()].strip()

    number = HOUSE_NUMBER.match(text)
    if number:
        fields["number"] = " ".join(part for part in number.groups() if part)
        text = text[number.end():].strip()

    words = text.split()
    if words:
        canonical = STREET_TYPES.get(fold(words[0]))
        if canonical:
            # Rewrite the abbreviation, so two spellings of one street compare
            # equal downstream.
            fields["street_type"] = canonical
            words[0] = canonical
        fields["street"] = " ".join(words)
    return fields

JavaScript

snippets/parse-address-into-fields/n0.js
/**
 * Parse a postal address into fields: regular expressions anchored on the
 * postcode.
 *
 * Rung N0. Deterministic, no dependency, and short enough to read in one
 * sitting.
 *
 * Two things make this work.
 *
 * First, the postcode is the anchor. Five digits in a row cut a French address
 * in two: what comes before is the street line, what comes after is the town.
 * Recognising the town by its name would mean shipping a list of communes and
 * keeping it up to date.
 *
 * Second, the street type is read from a dictionary rather than guessed, so the
 * abbreviations people actually type — « av. », « bd », « imp. » — come out as
 * one canonical spelling.
 */

// The street types of a French address, with the abbreviations people type.
// This is trade knowledge, not example data, so it belongs in the snippet.
export const STREET_TYPES = {
  r: 'rue', rue: 'rue',
  av: 'avenue', ave: 'avenue', avenue: 'avenue',
  bd: 'boulevard', bld: 'boulevard', boul: 'boulevard', boulevard: 'boulevard',
  imp: 'impasse', impasse: 'impasse',
  all: 'allée', allee: 'allée',
  ch: 'chemin', chemin: 'chemin',
  pl: 'place', place: 'place',
  rte: 'route', route: 'route',
  quai: 'quai', cours: 'cours', crs: 'cours',
  sq: 'square', square: 'square',
  voie: 'voie', passage: 'passage', sentier: 'sentier',
  chaussee: 'chaussée', fbg: 'faubourg', faubourg: 'faubourg',
  cite: 'cité', villa: 'villa', esplanade: 'esplanade',
};

export const FIELDS = ['number', 'street_type', 'street', 'postcode', 'city'];

// A house number, and the repetition index that may follow it: 8, 8 bis, 12B.
const HOUSE_NUMBER = /^(\d{1,4})\s*(bis|ter|quater|[a-z])?\b/i;

// A French postcode: five digits standing alone.
const POSTCODE = /\b\d{5}\b/g;

/** Reduce commas, line breaks and exotic spaces to a single plain space. */
export function normalise(text) {
  return text.normalize('NFKC').replaceAll(',', ' ').replace(/\s+/g, ' ').trim();
}

/** Lowercase, drop the accents and the trailing dot, for lookup only. */
export function fold(word) {
  return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/\.+$/, '');
}

/**
 * Split an address into number, street type, street, postcode and town.
 *
 * Every field is a string, empty when the address does not carry it. Returning
 * an empty string rather than nothing at all keeps the caller from having to
 * test each field before printing it.
 */
export function parse(address) {
  const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
  let text = normalise(address);

  // The anchor. Take the last postcode: a street name may carry a year, and a
  // town never comes before its postcode in the French convention.
  const postcodes = [...text.matchAll(POSTCODE)];
  if (postcodes.length > 0) {
    const found = postcodes.at(-1);
    fields.postcode = found[0];
    fields.city = text.slice(found.index + found[0].length).trim();
    text = text.slice(0, found.index).trim();
  }

  const number = HOUSE_NUMBER.exec(text);
  if (number) {
    fields.number = number.slice(1).filter(Boolean).join(' ');
    text = text.slice(number[0].length).trim();
  }

  const words = text.split(' ').filter(Boolean);
  if (words.length > 0) {
    const canonical = STREET_TYPES[fold(words[0])];
    if (canonical) {
      // Rewrite the abbreviation, so two spellings of one street compare equal
      // downstream.
      fields.street_type = canonical;
      words[0] = canonical;
    }
    fields.street = words.join(' ');
  }
  return fields;
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable unitairement
Dépendance fournisseur
Aucune
Empreinte
Négligeable
Périmètre réglementaire
  • Aucun périmètre spécifique ajouté : l'adresse ne quitte pas votre infrastructure

Point de rupture

Le complément d'adresse n'a pas d'ancre à lui : dans « 8 rue des Lilas Bâtiment C Appartement 12, 75011 Paris », le bâtiment et l'appartement finissent dans le nom de la rue, et le même complément écrit devant prend la place du numéro. Hors de France, l'ancre elle-même tombe : « Hauptstrasse 5, 10115 Berlin » laisse le numéro dans la rue, et « 42 Rowan Street, Bristol BS1 4TQ », sans suite de cinq chiffres, ressort sans code postal ni ville.

Quand monter d’un barreau

Vos formulaires reçoivent des compléments — bâtiment, escalier, résidence, appartement — et vous les retrouvez collés au nom de la rue dans vos exports.

N1 — Modèle classique léger Modèle classique léger Recommandé

Étiquetage des jetons par régression logistique sur le contexte

Coût
Négligeable
Latence
~10 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/parse-address-into-fields/n1.py
"""
Label every token of an address with a logistic regression on context traits.

Rung N1. N0 reads the address it expects; this one reads the address it is
given. Each word is labelled on its own — number, street type, street name,
complement, postcode, town — from what it looks like and from what sits on
either side of it. Fields are then rebuilt from the labels.

That is the whole gain: a complement in the middle of the line no longer
swallows the street, because « Bâtiment » is a word the model has seen in that
position, not an unexpected token in a fixed pattern.

The price is a labelled training set. A few dozen addresses tagged by hand are
enough to start, and every convention absent from that set is a convention the
model does not know.
"""

import re
import unicodedata

from sklearn.feature_extraction import DictVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

# The labels are the fields, so the mapping back is a grouping and nothing more.
LABELS = ("number", "street_type", "street", "complement", "postcode", "city")

TOKEN = re.compile(r"[^\W_]+")


def tokenise(address: str) -> list[str]:
    """Words and numbers, punctuation dropped."""
    return TOKEN.findall(unicodedata.normalize("NFKC", address))


def fold(word: str) -> str:
    """Lowercase and accent-free, so « Allée » and « allee » share a trait."""
    decomposed = unicodedata.normalize("NFD", word.lower())
    return "".join(c for c in decomposed if not unicodedata.combining(c))


def features(tokens: list[str], i: int) -> dict:
    """
    What the token looks like, and what surrounds it.

    The neighbours carry most of the signal: five digits followed by one
    capitalised word is a postcode and a town, wherever it sits in the line.
    """
    token = tokens[i]
    return {
        f"token={fold(token)}": 1,
        f"previous={fold(tokens[i - 1]) if i else '<start>'}": 1,
        f"next={fold(tokens[i + 1]) if i + 1 < len(tokens) else '<end>'}": 1,
        "digits": float(token.isdigit()),
        "five_digits": float(token.isdigit() and len(token) == 5),
        "short_number": float(token.isdigit() and len(token) <= 3),
        "capitalised": float(token[:1].isupper()),
        "position": i / len(tokens),
        "last": float(i == len(tokens) - 1),
    }


def train(examples):
    """
    `examples` pairs an address with one label per token, in reading order.

    Tagging is the work of this rung, and mistagging it is the usual way the
    rung is made to fail, so a misaligned example is rejected rather than
    quietly learnt.
    """
    rows, targets = [], []
    for address, labels in examples:
        tokens = tokenise(address)
        if len(tokens) != len(labels):
            raise ValueError(f"{len(tokens)} tokens for {len(labels)} labels: {address!r}")
        rows += [features(tokens, i) for i in range(len(tokens))]
        targets += list(labels)
    model = make_pipeline(
        DictVectorizer(),
        LogisticRegression(max_iter=1000, class_weight="balanced"),
    )
    model.fit(rows, targets)
    return model


def parse(model, address: str) -> dict:
    """Group the labelled tokens back into fields, in reading order."""
    fields = dict.fromkeys(LABELS, "")
    tokens = tokenise(address)
    if not tokens:
        return fields
    predicted = model.predict([features(tokens, i) for i in range(len(tokens))])
    for token, label in zip(tokens, predicted):
        fields[label] = f"{fields[label]} {token}".strip()
    # The street type stays available on its own, and also opens the street, so
    # the output can be compared field by field with the rung below.
    fields["street"] = f"{fields['street_type']} {fields['street']}".strip()
    return fields

JavaScript

snippets/parse-address-into-fields/n1.js
/**
 * Label every token of an address with a logistic regression on context traits.
 *
 * Rung N1. N0 reads the address it expects; this one reads the address it is
 * given. Each word is labelled on its own — number, street type, street name,
 * complement, postcode, town — from what it looks like and from what sits on
 * either side of it. Fields are then rebuilt from the labels.
 *
 * That is the whole gain: a complement in the middle of the line no longer
 * swallows the street, because « Bâtiment » is a word the model has seen in
 * that position, not an unexpected token in a fixed pattern.
 *
 * One binary regression per label, trained by plain gradient descent, and the
 * strongest one wins. Written out rather than pulled from a library, because
 * that is forty lines and it is the argument of this rung.
 */

// The labels are the fields, so the mapping back is a grouping and nothing more.
export const LABELS = ['number', 'street_type', 'street', 'complement', 'postcode', 'city'];

const TOKEN = /[\p{L}\p{N}]+/gu;

/** Words and numbers, punctuation dropped. */
export function tokenise(address) {
  return address.normalize('NFKC').match(TOKEN) ?? [];
}

/** Lowercase and accent-free, so « Allée » and « allee » share a trait. */
export function fold(word) {
  return word.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '');
}

const isDigits = (token) => /^\d+$/.test(token);

/**
 * What the token looks like, and what surrounds it.
 *
 * The neighbours carry most of the signal: five digits followed by one
 * capitalised word is a postcode and a town, wherever it sits in the line.
 */
export function features(tokens, i) {
  const token = tokens[i];
  return {
    [`token=${fold(token)}`]: 1,
    [`previous=${i > 0 ? fold(tokens[i - 1]) : '<start>'}`]: 1,
    [`next=${i + 1 < tokens.length ? fold(tokens[i + 1]) : '<end>'}`]: 1,
    digits: Number(isDigits(token)),
    five_digits: Number(isDigits(token) && token.length === 5),
    short_number: Number(isDigits(token) && token.length <= 3),
    capitalised: Number(token[0] !== token[0].toLowerCase()),
    position: i / tokens.length,
    last: Number(i === tokens.length - 1),
  };
}

/**
 * `examples` pairs an address with one label per token, in reading order.
 *
 * Tagging is the work of this rung, and mistagging it is the usual way the rung
 * is made to fail, so a misaligned example is rejected rather than quietly
 * learnt.
 */
export function train(examples, { epochs = 300, rate = 0.3 } = {}) {
  const rows = [];
  const targets = [];
  for (const [address, labels] of examples) {
    const tokens = tokenise(address);
    if (tokens.length !== labels.length) {
      throw new Error(`${tokens.length} tokens for ${labels.length} labels: ${address}`);
    }
    tokens.forEach((_, i) => {
      rows.push(features(tokens, i));
      targets.push(labels[i]);
    });
  }

  // One column per trait seen in training. A trait absent from this map is a
  // word the model never met, and it simply contributes nothing at prediction.
  const columns = new Map();
  const vectors = rows.map((row) =>
    Object.entries(row).map(([name, value]) => {
      if (!columns.has(name)) columns.set(name, columns.size);
      return [columns.get(name), value];
    }));

  const weights = {};
  for (const label of new Set(targets)) {
    const w = new Float64Array(columns.size + 1); // the last cell is the bias
    for (let epoch = 0; epoch < epochs; epoch += 1) {
      for (let i = 0; i < vectors.length; i += 1) {
        const target = targets[i] === label ? 1 : 0;
        const error = 1 / (1 + Math.exp(-score(w, vectors[i]))) - target;
        for (const [column, value] of vectors[i]) w[column] -= rate * error * value;
        w[columns.size] -= rate * error;
      }
    }
    weights[label] = w;
  }
  return { columns, weights };
}

/** Group the labelled tokens back into fields, in reading order. */
export function parse(model, address) {
  const fields = Object.fromEntries(LABELS.map((name) => [name, '']));
  const tokens = tokenise(address);
  tokens.forEach((token, i) => {
    const vector = vectorise(features(tokens, i), model.columns);
    const label = Object.keys(model.weights).reduce((best, candidate) =>
      score(model.weights[candidate], vector) > score(model.weights[best], vector) ? candidate : best);
    fields[label] = `${fields[label]} ${token}`.trim();
  });
  // The street type stays available on its own, and also opens the street, so
  // the output can be compared field by field with the rung below.
  fields.street = `${fields.street_type} ${fields.street}`.trim();
  return fields;
}

function vectorise(row, columns) {
  return Object.entries(row)
    .filter(([name]) => columns.has(name))
    .map(([name, value]) => [columns.get(name), value]);
}

function score(weights, vector) {
  let total = weights[weights.length - 1];
  for (const [column, value] of vector) total += weights[column] * value;
  return total;
}

Risques

Sortie de données
Reste dans votre infrastructure
Déterminisme
Oui
Testabilité
Testable statistiquement
Dépendance fournisseur
Bibliothèque
Empreinte
Faible
Périmètre réglementaire
  • Traitement de données personnelles sur votre infrastructure
  • Le jeu d'entraînement est étiqueté à la main : constitué d'adresses réelles, il entre dans votre registre des traitements si vous en tenez un

Point de rupture

Le modèle ne connaît que les conventions qu'on lui a étiquetées, et toutes les adresses de son jeu d'entraînement placent le numéro devant et cinq chiffres avant la ville. Sur « Hauptstrasse 5, 10115 Berlin », il rend une rue vide et un numéro qui vaut 5 ; sur « 42 Rowan Street, Bristol BS1 4TQ », ni code postal ni ville — et comme rien ne lui permet de dire qu'il n'a jamais vu ça, il étiquette quand même.

Quand monter d’un barreau

Des adresses étrangères arrivent, et couvrir chaque nouveau pays demanderait sa propre campagne d'étiquetage.

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

Analyseur d'adresses statistique auto-hébergé (libpostal)

Coût
Faible
Latence
~10 ms

Preuve d’exécution : Code exécuté, service externe simulé

Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.

Python

snippets/parse-address-into-fields/n2.py
"""
Parse an address with a self-hosted statistical parser.

Rung N2. libpostal is a parser trained on tens of millions of addresses from
open worldwide data. It labels a token from its context, like N1, but its
training set is the planet: the German number that comes after the street, the
British postcode that is not five digits, the Japanese order that starts with
the prefecture. It runs on your machine, so no address ever leaves it.

What you own on this rung is not the model. It is the batch, the size cap, the
mapping from its label set to yours, and the answer to « what does the code do
when the parser returns something we cannot use ». The model is a black box
with a fixed list of labels and no confidence score, and the last function
below is where that becomes your problem.
"""

from __future__ import annotations

# A postal address is a short line. Anything longer is a paste, and feeding it
# to the parser only produces confident nonsense more slowly.
MAX_CHARACTERS = 300

FIELDS = ("number", "street", "complement", "postcode", "city")

# libpostal's label set is its own, and wider than ours. Two of its labels can
# land in one of our fields, and everything unmapped is dropped on purpose: an
# unmapped label that silently became a field would be a surprise in a letter.
COMPONENTS = {
    "house_number": "number",
    "road": "street",
    "unit": "complement",
    "level": "complement",
    "staircase": "complement",
    "entrance": "complement",
    "postcode": "postcode",
    "city": "city",
}


class ParsingUnavailable(Exception):
    """The parser failed, or answered something no caller can act on."""


class LibpostalParser:
    """The real parser: a C library and its data files, loaded once."""

    def __init__(self) -> None:
        from postal.parser import parse_address  # a large local install

        self._parse_address = parse_address

    def predict(self, addresses: list[str]) -> list[dict]:
        """One label-to-value mapping per address, in the order given."""
        return [self._merge(self._parse_address(a)) for a in addresses]

    @staticmethod
    def _merge(components) -> dict:
        """libpostal yields (value, label) pairs, and repeats a label freely."""
        merged: dict[str, str] = {}
        for value, label in components:
            merged[label] = f"{merged.get(label, '')} {value}".strip()
        return merged


def parse_addresses(addresses, parser=None, *, attempts: int = 2) -> list[dict]:
    """
    Parse a batch of addresses into fields.

    `parser` is injected so this can be tested without installing the model. In
    production it defaults to the real one above.

    The whole batch goes in one call. Parsing addresses one by one is the usual
    way this rung is made slow, because the model is loaded once and a batch of
    a hundred is one pass through it.
    """
    parser = parser or LibpostalParser()
    batch = list(addresses)
    for address in batch:
        if len(address) > MAX_CHARACTERS:
            raise ValueError(f"address longer than {MAX_CHARACTERS} characters")
    if not batch:
        return []

    rows = _predict(parser, batch, attempts)
    if len(rows) != len(batch):
        raise ParsingUnavailable("the parser returned one row per address, and did not")
    return [_to_fields(row) for row in rows]


def _predict(parser, batch: list[str], attempts: int) -> list[dict]:
    """Retry once: loading the data files is the call that fails, and once."""
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            return parser.predict(batch)
        except Exception as error:  # noqa: BLE001 - any model failure is retried
            last_error = error
    raise ParsingUnavailable(str(last_error))


def _to_fields(row) -> dict:
    """Keep the labels we mapped, join those that share a field, drop the rest."""
    fields = dict.fromkeys(FIELDS, "")
    for label, value in (row or {}).items():
        field = COMPONENTS.get(label)
        if field and isinstance(value, str) and value.strip():
            fields[field] = f"{fields[field]} {value.strip()}".strip()
    return fields

JavaScript

snippets/parse-address-into-fields/n2.js
/**
 * Parse an address with a self-hosted statistical parser.
 *
 * Rung N2. libpostal is a parser trained on tens of millions of addresses from
 * open worldwide data. It labels a token from its context, like N1, but its
 * training set is the planet: the German number that comes after the street,
 * the British postcode that is not five digits, the Japanese order that starts
 * with the prefecture. It runs on your machine, so no address ever leaves it.
 *
 * What you own on this rung is not the model. It is the batch, the size cap,
 * the mapping from its label set to yours, and the answer to « what does the
 * code do when the parser returns something we cannot use ». The model is a
 * black box with a fixed list of labels and no confidence score, and the last
 * function below is where that becomes your problem.
 */

// A postal address is a short line. Anything longer is a paste, and feeding it
// to the parser only produces confident nonsense more slowly.
export const MAX_CHARACTERS = 300;

export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];

// libpostal's label set is its own, and wider than ours. Two of its labels can
// land in one of our fields, and everything unmapped is dropped on purpose: an
// unmapped label that silently became a field would be a surprise in a letter.
const COMPONENTS = {
  house_number: 'number',
  road: 'street',
  unit: 'complement',
  level: 'complement',
  staircase: 'complement',
  entrance: 'complement',
  postcode: 'postcode',
  city: 'city',
};

export class ParsingUnavailable extends Error {}

/** The real parser: a native binding and its data files, loaded once. */
export class LibpostalParser {
  static async load() {
    const postal = await import('node-postal'); // a large local install
    return new LibpostalParser((address) => postal.parser.parse_address(address));
  }

  constructor(parseAddress) {
    this.parseAddress = parseAddress;
  }

  /** One label-to-value mapping per address, in the order given. */
  async predict(addresses) {
    return addresses.map((address) => merge(this.parseAddress(address)));
  }
}

/** libpostal yields {component, value} pairs, and repeats a component freely. */
function merge(components) {
  const merged = {};
  for (const { component, value } of components) {
    merged[component] = `${merged[component] ?? ''} ${value}`.trim();
  }
  return merged;
}

/**
 * Parse a batch of addresses into fields.
 *
 * `parser` is injected so this can be tested without installing the model. In
 * production it defaults to the real one above.
 *
 * The whole batch goes in one call. Parsing addresses one by one is the usual
 * way this rung is made slow, because the model is loaded once and a batch of a
 * hundred is one pass through it.
 */
export async function parseAddresses(addresses, parser, { attempts = 2 } = {}) {
  const model = parser ?? (await LibpostalParser.load());
  const batch = [...addresses];
  for (const address of batch) {
    if (address.length > MAX_CHARACTERS) {
      throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
    }
  }
  if (batch.length === 0) return [];

  const rows = await predict(model, batch, attempts);
  if (rows.length !== batch.length) {
    throw new ParsingUnavailable('the parser returned one row per address, and did not');
  }
  return rows.map(toFields);
}

/** Retry once: loading the data files is the call that fails, and once. */
async function predict(parser, batch, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      return await parser.predict(batch);
    } catch (error) {
      lastError = error;
    }
  }
  throw new ParsingUnavailable(String(lastError));
}

/** Keep the labels we mapped, join those that share a field, drop the rest. */
function toFields(row) {
  const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
  for (const [label, value] of Object.entries(row ?? {})) {
    const field = COMPONENTS[label];
    if (field && typeof value === 'string' && value.trim()) {
      fields[field] = `${fields[field]} ${value.trim()}`.trim();
    }
  }
  return fields;
}

Risques

Sortie de données
Reste dans votre infrastructure
Déterminisme
Oui
Testabilité
Testable statistiquement
Dépendance fournisseur
Bibliothèque
Empreinte
Modérée
Périmètre réglementaire
  • Traitement de données personnelles sur votre infrastructure, dans un service permanent
  • Aucun transfert à un tiers : le modèle et ses fichiers de données sont installés sur votre machine

Point de rupture

Il découpe, il ne vérifie pas. Une ligne qui n'est pas une adresse — « the meeting is at ten in room four » — ressort avec un numéro et une rue, et « 8 rue des Lilas, 75011 Lyon », qui pose un code postal parisien sur une autre ville, ressort en champs propres. L'analyseur ne rend ni score ni refus : rien, dans le code, ne distingue ce résultat d'un bon.

Quand monter d’un barreau

Ce que vous recevez n'est plus une ligne d'adresse mais du texte libre — une signature de courriel, un message où l'adresse est noyée dans une phrase — et il n'y a plus de ligne à découper.

N3 — API de LLM généraliste API de LLM généraliste

Découpage structuré par appel à un modèle généraliste

Coût
Élevé
Latence
~1 s

Preuve d’exécution : Code exécuté, service externe simulé

Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.

Python

snippets/parse-address-into-fields/n3.py
"""
Split an address by asking a general-purpose model.

Rung N3. This is the option people reach for first. It is here so you can see
what it costs, not because this entry recommends it.

Note what the code has to do that N0 did not: cap the input size, retry on
failure, parse an answer that is only probably valid JSON, and check that the
fields it hands back were actually in the address. That last point is specific
to extraction: a model asked for a postcode and given none will happily supply
a plausible one, and a plausible postcode is worse than an empty field because
nothing downstream will ever question it.

That plumbing is the real cost of this rung, and it is the part your tests have
to cover, because the model itself is not testable.
"""

from __future__ import annotations

import json
import re
import unicodedata

FIELDS = ("number", "street", "complement", "postcode", "city")

PROMPT = (
    "Split the postal address below into fields.\n"
    "Answer with JSON only: an object with the keys `number`, `street`,\n"
    "`complement`, `postcode` and `city`. Copy the text exactly as it is\n"
    "written, and leave a key empty when the address does not carry it.\n\n"
    "Address:\n{address}"
)

# An address is a short line. A cap is not an optimisation here, it is a cost
# control: a model charges by the token, on the way in as well as out.
MAX_CHARACTERS = 300


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


def parse(address: str, client=None, *, attempts: int = 3) -> dict:
    """
    Split an address into number, street, complement, postcode and town.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    if client is None:  # pragma: no cover - needs a key and a network
        from openai import OpenAI

        client = OpenAI()

    if len(address) > MAX_CHARACTERS:
        raise ValueError(f"address longer than {MAX_CHARACTERS} characters")

    answer = _ask(client, address, attempts)
    source = _fold(address)
    fields = dict.fromkeys(FIELDS, "")
    for key in FIELDS:
        value = answer.get(key)
        if isinstance(value, str) and value.strip() and _fold(value) in source:
            # Kept only if the model copied it from the address. What it made
            # up is dropped, and an empty field is a question a human can see.
            fields[key] = value.strip()
    return fields


def _ask(client, address: str, attempts: int) -> dict:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=PROMPT.format(address=address), temperature=0)
            parsed = json.loads(answer)
            if isinstance(parsed, dict):
                return parsed
            last_error = ValueError("the model answered something that is not an object")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ParsingUnavailable(str(last_error))


def _fold(text: str) -> str:
    """Case and spacing are the model's to change; the words are not."""
    return re.sub(r"\s+", " ", unicodedata.normalize("NFKC", text).lower()).strip()

JavaScript

snippets/parse-address-into-fields/n3.js
/**
 * Split an address by asking a general-purpose model.
 *
 * Rung N3. This is the option people reach for first. It is here so you can see
 * what it costs, not because this entry recommends it.
 *
 * Note what the code has to do that N0 did not: cap the input size, retry on
 * failure, parse an answer that is only probably valid JSON, and check that the
 * fields it hands back were actually in the address. That last point is
 * specific to extraction: a model asked for a postcode and given none will
 * happily supply a plausible one, and a plausible postcode is worse than an
 * empty field because nothing downstream will ever question it.
 *
 * That plumbing is the real cost of this rung, and it is the part your tests
 * have to cover, because the model itself is not testable.
 */

export const FIELDS = ['number', 'street', 'complement', 'postcode', 'city'];

const PROMPT = [
  'Split the postal address below into fields.',
  'Answer with JSON only: an object with the keys `number`, `street`,',
  '`complement`, `postcode` and `city`. Copy the text exactly as it is',
  'written, and leave a key empty when the address does not carry it.',
  '',
  'Address:',
].join('\n');

// An address is a short line. A cap is not an optimisation here, it is a cost
// control: a model charges by the token, on the way in as well as out.
export const MAX_CHARACTERS = 300;

export class ParsingUnavailable extends Error {}

/**
 * Split an address into number, street, complement, postcode and town.
 *
 * @param {string} address
 * @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.attempts]
 */
export async function parse(address, { client, attempts = 3 } = {}) {
  if (!client) {
    // Needs a key and a network, so it is never reached in the tests.
    const { OpenAI } = await import('openai');
    client = new OpenAI();
  }

  if (address.length > MAX_CHARACTERS) {
    throw new RangeError(`address longer than ${MAX_CHARACTERS} characters`);
  }

  const answer = await ask(client, address, attempts);
  const source = fold(address);
  const fields = Object.fromEntries(FIELDS.map((name) => [name, '']));
  for (const key of FIELDS) {
    const value = answer[key];
    if (typeof value === 'string' && value.trim() && source.includes(fold(value))) {
      // Kept only if the model copied it from the address. What it made up is
      // dropped, and an empty field is a question a human can see.
      fields[key] = value.trim();
    }
  }
  return fields;
}

async function ask(client, address, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({
        prompt: `${PROMPT}\n${address}`,
        // Temperature zero, because an address that splits differently between
        // two identical calls cannot be reconciled with anything.
        temperature: 0,
      });
      const parsed = JSON.parse(answer);
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
      lastError = new Error('the model answered something that is not an object');
    } catch (error) {
      lastError = error;
    }
  }
  throw new ParsingUnavailable(String(lastError));
}

/** Case and spacing are the model's to change; the words are not. */
function fold(text) {
  return text.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
}

Risques

Sortie de données
Part chez un tiers
Déterminisme
Non
Testabilité
Difficilement testable
Dépendance fournisseur
Fournisseur externe
Empreinte
Élevée
Périmètre réglementaire
  • Transfert de données personnelles à un sous-traitant, avec l'encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas de vos propres obligations d'information et de minimisation

Point de rupture

La garde de l'extrait vérifie d'où vient une valeur, pas si elle est à sa place. Sur « 12 rue de Lille, 59000 Lille », le modèle intervertit la rue et la ville ; les deux valeurs ayant bien été copiées de l'adresse, les deux passent le contrôle, et la réponse revient proprement structurée et fausse.

Quand monter d’un barreau

Il n'y a pas de barreau au-dessus.

Le verdict

RecommandéN1

N1 pour une raison précise : le complément. Bâtiment, escalier, résidence, appartement, c'est ce que vos formulaires reçoivent vraiment, et N0 le colle dans le nom de la rue sans jamais le signaler. Le prix de N1 est un jeu de quelques dizaines d'adresses étiquetées à la main, qui reste chez vous et se mesure ; montez à N2 le jour où des adresses étrangères arrivent, parce qu'élargir l'étiquetage pays par pays coûte alors plus cher qu'installer un analyseur déjà entraîné sur le monde entier.

Pour aller plus loin

Métadonnées