Repérer les personnes et organisations citées dans un article
Lister les noms propres mentionnés dans un texte et savoir à quel type d’entité chacun correspond.
De l’IA, mais un petit modèle chez vous
négligeableRapideVos données ne sortent pas
Le verdict
N2, parce que la moitié difficile de l’énoncé — dire ce qu’un nom est — n’est écrite nulle part dans le texte, et qu’un modèle de quelques dizaines de mégaoctets qui tourne chez vous la règle. Ce n’est pas un renoncement à la frugalité, c’est où elle mène : le pipeline est local, gratuit, déterministe, et il ne fait pas sortir une liste de noms de personnes. Le niveau N0 garde tout son emploi, et il fait deux choses que le modèle ne fait pas. Il répond `unknown` quand la phrase ne prouve rien, quand le pipeline affirme toujours quelque chose. Et il compte ce qu’il a laissé passer — les noms d’un seul mot en tête de phrase — au lieu de les taire. Une décision est assumée au passage : « et » n’est pas admis à l’intérieur d’un nom, ce qui coupe « Marks and Spencer » en deux morceaux, parce que l’admettre fusionnait « Alex Ferguson et Acme » en une seule entité — inventer une entité à partir de deux est pire que rendre un nom en deux morceaux.
Un pipeline de reconnaissance d’entités chargé localement
De l’IA, mais un petit modèle chez vous
Petit modèle spécialisé auto-hébergéN2négligeableRapide
Python
"""
Typer les noms avec un petit modèle pré-entraîné, sur votre machine.
Niveau N2. C'est le niveau qui répond à la moitié dont le niveau N0 est
incapable : ce qu'un nom *est*, quand la phrase ne le dit pas. Le modèle pèse
quelques dizaines de mégaoctets, il tourne sur un processeur en quelques
millisecondes, et rien ne quitte la machine — ce qui compte davantage ici
qu'ailleurs, puisque ce qu'on extrait est une liste de personnes.
Ce qui vous appartient à ce niveau n'est pas le modèle. C'est le plafond, le
lot, la correspondance entre son jeu d'étiquettes et le vôtre, et la réponse
à « que fait le code d'une étiquette qu'on ne sait pas traduire ». Le
pipeline français de spaCy porte quatre étiquettes — personne, organisation,
lieu, et une quatrième qui veut dire « autre chose » — là où cette fiche a
trois sens. `MISC` est donc écarté plutôt que deviné, et compté, pour que
l'appelant voie ce qui a été jeté.
La limite à garder en tête est la forme de la réponse, pas sa qualité : le
pipeline rend une étiquette pour chaque nom qu'il trouve, et rien à côté.
Il n'y a pas de seuil à monter ni d'« unknown » sur quoi retomber. Le niveau
N0 répond `unknown` quand la phrase ne prouve rien ; ici, on affirme toujours
quelque chose.
"""
from __future__ import annotations
# The pipeline name is an example: pick the one trained for your language, and
# read its licence — the French pipelines of spaCy are published under
# LGPL-LR, which is not the licence of spaCy itself.
MODEL = "fr_core_news_sm"
# A document longer than this is cut, not refused: a recogniser that raises on
# a long article returns nothing at all.
MAX_CHARACTERS = 20000
# Their label set is theirs, and wider than ours. What is not mapped is
# dropped on purpose: an unmapped label that silently became a type would be a
# surprise in a list of people.
LABELS = {"PER": "person", "PERSON": "person", "ORG": "company", "LOC": "place",
"GPE": "place"}
class SpacyRecogniser:
"""The real recogniser: a pipeline loaded once, then fed in batches."""
def __init__(self, model: str = MODEL):
import spacy # a large local install, and a model to download
self._nlp = spacy.load(model, disable=["lemmatizer", "textcat"])
def predict(self, texts: list[str]) -> list[dict]:
return [{"entities": [{"text": entity.text, "label": entity.label_,
"start": entity.start_char, "end": entity.end_char}
for entity in document.ents]}
for document in self._nlp.pipe(texts)]
class RecognitionUnavailable(Exception):
"""The recogniser could not be loaded, or answered something unusable."""
def extract_names(texts, recogniser=None) -> list[dict]:
"""
For each text, the names the model found, with its labels mapped to ours.
`recogniser` is injected so this function can be tested without the model.
In production it defaults to the real pipeline.
"""
recogniser = recogniser or SpacyRecogniser()
cut = [text[:MAX_CHARACTERS] if isinstance(text, str) else "" for text in texts]
try:
answers = recogniser.predict(cut)
except Exception as error: # noqa: BLE001 - a local model failing is still a failure
raise RecognitionUnavailable(str(error)) from error
if not isinstance(answers, list) or len(answers) != len(cut):
raise RecognitionUnavailable("the recogniser did not answer once per text")
reports = []
for text, answer in zip(cut, answers):
names, unmapped = [], []
for entity in (answer or {}).get("entities", []):
label = str(entity.get("label", ""))
found = str(entity.get("text", ""))
if label not in LABELS:
unmapped.append(label)
elif found and found in text:
names.append({"text": found, "type": LABELS[label], "evidence": label,
"start": int(entity.get("start", text.index(found))),
"end": int(entity.get("end", text.index(found) + len(found)))})
reports.append({"names": names, "unmapped": unmapped,
"characters_read": len(text)})
return reportsJavaScript
/**
* Typer les noms avec un petit modèle pré-entraîné, sur votre machine.
*
* Niveau N2. C'est le niveau qui répond à la moitié dont le niveau N0 est
* incapable : ce qu'un nom *est*, quand la phrase ne le dit pas. Le modèle pèse
* quelques dizaines de mégaoctets, il tourne sur un processeur en quelques
* millisecondes, et rien ne quitte la machine — ce qui compte davantage ici
* qu'ailleurs, puisque ce qu'on extrait est une liste de personnes.
*
* Ce qui vous appartient à ce niveau n'est pas le modèle. C'est le plafond, le
* lot, la correspondance entre son jeu d'étiquettes et le vôtre, et la réponse
* à « que fait le code d'une étiquette qu'on ne sait pas traduire ». Le
* pipeline français de spaCy porte quatre étiquettes — personne, organisation,
* lieu, et une quatrième qui veut dire « autre chose » — là où cette fiche a
* trois sens. `MISC` est donc écarté plutôt que deviné, et compté, pour que
* l'appelant voie ce qui a été jeté.
*
* La limite à garder en tête est la forme de la réponse, pas sa qualité : le
* pipeline rend une étiquette pour chaque nom qu'il trouve, et rien à côté.
* Il n'y a pas de seuil à monter ni d'« unknown » sur quoi retomber. Le niveau
* N0 répond `unknown` quand la phrase ne prouve rien ; ici, on affirme toujours
* quelque chose.
*/
// The model name is an example: pick one trained for your language, and read
// its licence, which is not the licence of the runtime that loads it.
export const MODEL = 'Xenova/bert-base-multilingual-cased-ner-hrl';
// A document longer than this is cut, not refused: a recogniser that throws on
// a long article returns nothing at all.
export const MAX_CHARACTERS = 20000;
// Their label set is theirs, and wider than ours. What is not mapped is
// dropped on purpose: an unmapped label that silently became a type would be a
// surprise in a list of people.
export const LABELS = {
PER: 'person', PERSON: 'person', ORG: 'company', LOC: 'place', GPE: 'place',
};
/** The real recogniser: a pipeline loaded once, then fed in batches. */
export async function transformersRecogniser(model = MODEL) {
const { pipeline } = await import('@huggingface/transformers'); // a large local install
const run = await pipeline('token-classification', model, { aggregation_strategy: 'simple' });
return {
async predict(texts) {
const answers = await run(texts);
return answers.map((entities) => ({
entities: entities.map((entity) => ({
text: entity.word,
label: entity.entity_group,
start: entity.start,
end: entity.end,
})),
}));
},
};
}
export class RecognitionUnavailable extends Error {}
/**
* For each text, the names the model found, with its labels mapped to ours.
*
* `recogniser` is injected so this function can be tested without the model.
* In production it defaults to the real pipeline.
*/
export async function extractNames(texts, recogniser) {
recogniser ??= await transformersRecogniser();
const cut = texts.map((text) => (typeof text === 'string' ? text.slice(0, MAX_CHARACTERS) : ''));
let answers;
try {
answers = await recogniser.predict(cut);
} catch (error) {
throw new RecognitionUnavailable(String(error));
}
if (!Array.isArray(answers) || answers.length !== cut.length) {
throw new RecognitionUnavailable('the recogniser did not answer once per text');
}
return cut.map((text, index) => {
const names = [];
const unmapped = [];
for (const entity of (answers[index] ?? {}).entities ?? []) {
const label = String(entity.label ?? '');
const found = String(entity.text ?? '');
if (!(label in LABELS)) {
unmapped.push(label);
} else if (found && text.includes(found)) {
names.push({
text: found,
type: LABELS[label],
evidence: label,
start: Number(entity.start ?? text.indexOf(found)),
end: Number(entity.end ?? text.indexOf(found) + found.length),
});
}
}
return { names, unmapped, characters_read: text.length };
});
}Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
Les étiquettes rendues par le modèle sont simulées par le double local qui sert aux tests. Ce qui est calculé ici, c’est ce que le code en fait : ce qu’il traduit, ce qu’il écarte, et ce qu’il refuse.
Un paragraphe de presse, avec ses marqueurs
Donné
Le contrat lie la société Lumière SARL à Jean de La Fontaine et à Mme Marie Martin, de Lyon.
Ce qu’il renvoie
4 nom(s) typé(s) par le modèle
Le détail
Nom Type rendu Étiquette du modèle Lumière SARL entreprise ORG Jean de La Fontaine personne PER Marie Martin personne PER Lyon lieu LOC Sans modèle, le niveau N0 répond : « Lumière SARL » entreprise, « Jean de La Fontaine » inconnu, « Marie Martin » personne, « Lyon » inconnu.
Le même paragraphe, sans aucun marqueur
Donné
Le contrat lie Lumière à Jean de La Fontaine et à Marie Martin, de Lyon.
Ce qu’il renvoie
4 nom(s) typé(s) par le modèle
Le détail
Nom Type rendu Étiquette du modèle Lumière entreprise ORG Jean de La Fontaine personne PER Marie Martin personne PER Lyon lieu LOC Sans modèle, le niveau N0 répond : « Lumière » inconnu, « Jean de La Fontaine » inconnu, « Marie Martin » inconnu, « Lyon » inconnu.
Une étiquette que la fiche ne sait pas traduire
Donné
Le prix Goncourt a été remis à Marie Martin à Paris.
Ce qu’il renvoie
2 nom(s) typé(s) par le modèle · 1 étiquette(s) écartée(s) faute de correspondance
Le détail
Nom Type rendu Étiquette du modèle Marie Martin personne PER Paris lieu LOC Sans modèle, le niveau N0 répond : « Goncourt » inconnu, « Marie Martin » inconnu, « Paris » inconnu.
Le modèle rend un nom que le texte ne contient pas
Donné
Le colis part de Lyon mercredi.
Ce qu’il renvoie
1 nom(s) typé(s) par le modèle
Le détail
Nom Type rendu Étiquette du modèle Lyon lieu LOC Sans modèle, le niveau N0 répond : « Lyon » inconnu.
Le modèle se trompe de type, et rien ne permet d’en douter
Donné
Le colis part de Lyon mercredi.
Ce qu’il renvoie
1 nom(s) typé(s) par le modèle
Le détail
Nom Type rendu Étiquette du modèle Lyon entreprise ORG Sans modèle, le niveau N0 répond : « Lyon » inconnu.
Là, ça casseLe modèle rend « Lyon » comme une entreprise, et le rapport l’affiche comme telle : il n’y a ni score, ni seuil, ni « inconnu » sur quoi retomber, parce que le pipeline n’en rend pas. Ce que le code peut vérifier — que le nom figure bien dans le texte, que l’étiquette est traduisible — est vérifié ; ce qu’il ne peut pas vérifier, c’est si l’étiquette est juste. Le niveau N0, lui, répond « inconnu » sur ce même nom, ce qui est exactement ce que la phrase prouve : la note sous le tableau le montre à chaque cas.
Le processus du modèle meurt
Donné
le même texte, mais le modèle ne répond pas
Ce qu’il renvoie
Le modèle n’a pas répondu
Le code lève une erreur nommée plutôt que de rendre une liste vide, qui se lirait comme « aucun nom dans ce texte ».
Sans modèle, le niveau N0 répond : « Lyon » inconnu.
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte modérée
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le modèle tourne chez vous et le texte ne sort pas
- Licence du modèle à lire séparément de celle de la bibliothèque : les pipelines français de spaCy sont publiés sous LGPL-LR, quand spaCy est sous MIT
Point de rupture
Le pipeline rend une étiquette pour chaque nom qu’il trouve, et rien à côté : pas de score, donc pas de seuil à monter et pas d’`unknown` sur quoi retomber — « Lyon » rendu comme une entreprise entre dans la liste comme une entreprise. Témoin dans le même test : sur ce même nom, le niveau N0 répond `unknown`.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus qui vaille la peine ici. Ce qui manque à ce niveau — savoir qu’il ne sait pas — ne s’achète pas à l’appel.
Les quatre options
- N0Les majuscules, et un type seulement là où la phrase le prouveGratuit · Instantané
- N1Niveau sans objet ici
- RecommandéN2Un pipeline de reconnaissance d’entités chargé localementnégligeable · Rapide
- N3Niveau sans objet ici
Les autres options
N0Les majuscules, et un type seulement là où la phrase le prouveGratuit · Instantané
Les majuscules, et un type seulement là où la phrase le prouve
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Les noms propres d'un article, typés seulement là où quelque chose le prouve.
Niveau N0. En français comme en anglais, un nom propre porte une majuscule :
trouver les candidats est donc un balayage. Dire ce que chacun *est* — une
personne, une entreprise, un lieu — est l'autre moitié, la difficile, et rien
dans les lettres elles-mêmes ne le dit : « Boulanger » est un nom de famille
autant qu'une enseigne, « Orange » une entreprise autant qu'un fruit, « Lyon »
une ville autant qu'un patronyme.
Ce niveau ne type donc un nom que lorsque la phrase le prouve. « M. » ou
« Mme » devant prouve une personne ; une forme juridique comme « SARL » ou
« Ltd » derrière, ou « la société » devant, prouve une entreprise. Tout le
reste revient en `unknown`, qui est ce que la phrase autorise réellement.
Deviner d'après la forme du mot, c'est ainsi que « Boulanger a livré le
colis » devient une boulangerie.
Deux limites sont comptées plutôt que cachées. Un mot capitalisé seul en tête
de phrase n'est pas rendu, parce que la plupart ne sont que le premier mot de
la phrase ; et un mot d'ouverture placé devant un vrai nom — « Après » dans
« Après Renault » — ne fait pas partie du nom. `skipped_at_sentence_start`
compte les deux. La liste de ces mots d'ouverture est déclarée, comme celle
des civilités et celle des formes juridiques : un niveau qui devinerait
lesquels sont des noms devinerait exactement ce qu'il annonce ne pas faire.
Les articles en sont absents, sans quoi « Le Monde » perdrait son journal.
Ce que ce niveau ne sait pas reconnaître du tout, c'est ce dont un nom est le
nom : une référence de produit comme « A350 » dans « Airbus a livré son
premier A350 » est capitalisée, ne suit aucun mot d'ouverture et ne porte
aucun marqueur, donc elle revient comme un nom `unknown`. C'est le niveau
au-dessus.
Et les marqueurs ci-dessous sont français et anglais : c'est une liste
déclarée, faite pour être étendue, et un texte qui n'en porte aucun revient
entièrement `unknown`.
"""
from __future__ import annotations
import re
# What proves a person, before the name.
HONORIFICS = {"m", "m.", "mme", "mlle", "dr", "dr.", "me", "pr", "prof", "prof.",
"mr", "mr.", "mrs", "mrs.", "ms", "ms.", "miss", "sir"}
# What proves a company, after the name.
LEGAL_FORMS = {"sarl", "sas", "sasu", "sa", "snc", "sci", "eurl", "scop", "gie",
"gmbh", "ag", "ltd", "ltd.", "inc", "inc.", "llc", "plc", "bv", "nv", "spa"}
# What proves a company, before the name. Two words at most, lowercase.
ORG_LEADS = {("la", "société"), ("l", "entreprise"), ("le", "groupe"), ("la", "marque"),
("the", "company"), ("the", "firm"), ("the", "group"), ("l", "enseigne")}
# Lowercase words a name may contain without being cut: « Jean de La Fontaine »,
# « Ludwig van Beethoven », « Banque de France ». « et » and « and » are
# deliberately absent, although « Marks and Spencer » wants them: keeping them
# merges « Alex Ferguson et Acme » into a single name, and inventing one entity
# out of two is worse than returning one name in two pieces.
PARTICLES = {"de", "du", "des", "da", "della", "van", "von", "der", "den", "ten",
"of", "la", "le", "les", "el", "al", "d", "l", "o"}
# Words that open a sentence and are never part of a name. A capitalised run
# that starts a sentence with one of them starts one word later: « Après
# Renault » is Renault, « Selon Le Monde » is Le Monde. The list is declared
# rather than inferred, like HONORIFICS and LEGAL_FORMS, because a rung that
# guessed which openers are names would be guessing exactly what it says it
# will not — and it is meant to be extended.
#
# Articles and particles are deliberately absent from it: « Le Monde » opens a
# sentence with « Le », and dropping that word would lose the newspaper.
SENTENCE_OPENERS = {
"après", "avant", "selon", "depuis", "chez", "pour", "contre", "malgré",
"dès", "lors", "entre", "face", "sans", "sous", "sur", "dans", "avec",
"par", "vers", "pendant", "durant", "outre", "parmi", "quant", "comme",
"alors", "ainsi", "aussi", "cependant", "pourtant", "toutefois", "enfin",
"ensuite", "puis", "donc", "mais", "car", "quand", "lorsque",
"ce", "cet", "cette", "ces", "son", "sa", "ses", "leur", "leurs",
"notre", "votre", "nos", "vos", "mon", "ma", "mes",
"il", "elle", "ils", "elles", "on", "nous", "vous",
"after", "before", "since", "according", "despite", "during", "among",
"between", "through", "under", "over", "about", "against", "within",
"without", "across", "around", "because", "although", "however",
"therefore", "meanwhile", "instead", "finally", "then", "when", "while",
"where", "this", "these", "that", "those", "their", "his", "her", "its",
"our", "your", "but", "and", "for", "nor", "yet", "with", "from",
}
# A word, with neither full stop nor apostrophe inside it. « M. » is therefore
# the token « M » followed by a separator, which keeps an honorific out of the
# name, and « L'enseigne » is two tokens, which keeps the article out of it.
WORD = re.compile(r"[^\W\d_][\w-]*", re.UNICODE)
# What two words of one name may be separated by: a space, or the apostrophe
# French elides on — « Jean d'Artagnan » is one name.
JOINS = re.compile(r"[ \t\r\n\f\v\u00a0\u202f]+|['’]")
SENTENCE_END = re.compile(r"[.!?…:;\n\r]\s*\Z")
def extract_names(text) -> dict:
"""
The proper names of `text`, each with what the sentence proves about it.
`type` is « person », « company » or « unknown », and `evidence` says what
proved it. A name with no evidence is never typed by its spelling.
`skipped_at_sentence_start` counts the capitalised words this rung set
aside because they open a sentence: a word alone, which it cannot tell from
a name, and the opener in front of a name — « Après » in « Après Renault ».
What this rung has no way to recognise at all is what a name is *of*: a
product reference like « A350 » in « Airbus a livré son premier A350 » is
capitalised, follows no opener and carries no evidence, so it comes back as
an `unknown` name. That is a rung above.
"""
if not isinstance(text, str):
return {"names": [], "skipped_at_sentence_start": 0,
"reason": f"expected text, not {type(text).__name__}"}
tokens = [(m.group(), m.start(), m.end()) for m in WORD.finditer(text)]
names, skipped, index = [], 0, 0
while index < len(tokens):
length = _run_length(tokens, index, text)
if length == 0:
index += 1
continue
start, end = tokens[index][1], tokens[index + length - 1][2]
opens = index == 0 or SENTENCE_END.search(text[tokens[index - 1][2]:start])
if opens and tokens[index][0].lower() in SENTENCE_OPENERS:
# « Après Renault, … » — the name starts one word later, and the
# word set aside is counted rather than swallowed.
skipped += 1
index += 1
continue
kind, evidence = _typed(tokens, index, length)
if length > 1 and tokens[index][0].lower() in HONORIFICS:
start = tokens[index + 1][1] # « Mme » is not part of the name
alone = tokens[index][0].lower()
ordinary = alone in PARTICLES or alone in HONORIFICS or len(alone) == 1
if length == 1 and opens and kind == "unknown" and not ordinary:
# « Lumière a livré le colis. » — most sentence openers are not
# names, and this rung has no way to tell which ones are. An
# opener that is plainly a determiner is not even counted.
skipped += 1
elif not (length == 1 and ordinary):
names.append({"text": text[start:end], "type": kind, "evidence": evidence,
"start": start, "end": end})
index += length
return {"names": names, "skipped_at_sentence_start": skipped, "reason": None}
def _run_length(tokens, index: int, text: str) -> int:
"""How many tokens from `index` belong to one capitalised run."""
if not tokens[index][0][:1].isupper():
return 0
length, last = 1, index
while last + 1 < len(tokens):
word = tokens[last + 1][0]
if not JOINS.fullmatch(text[tokens[last][2]:tokens[last + 1][1]]):
break
if word[:1].isupper():
last += 1
elif word.lower() in PARTICLES and last + 2 < len(tokens) \
and tokens[last + 2][0][:1].isupper():
last += 2 # a particle only counts between two capitalised words
else:
break
length = last - index + 1
return length
def _typed(tokens, index: int, length: int):
"""What the sentence proves about this run, and the word that proves it."""
before = [tokens[i][0].lower() for i in range(max(0, index - 2), index)]
run = [tokens[i][0].lower() for i in range(index, index + length)]
after = tokens[index + length][0].lower() if index + length < len(tokens) else ""
if before and before[-1] in HONORIFICS:
return "person", before[-1]
if length > 1 and run[0] in HONORIFICS:
return "person", run[0] # « Sir Alex », written without a full stop
if run[-1] in LEGAL_FORMS and length > 1:
return "company", run[-1]
if after in LEGAL_FORMS:
return "company", after
if len(before) == 2 and tuple(before) in ORG_LEADS:
# « l'entreprise » is two tokens; it is written back as one word.
return "company", ("'" if len(before[0]) == 1 else " ").join(before)
return "unknown", NoneJavaScript
/**
* Les noms propres d'un article, typés seulement là où quelque chose le prouve.
*
* Niveau N0. En français comme en anglais, un nom propre porte une majuscule :
* trouver les candidats est donc un balayage. Dire ce que chacun *est* — une
* personne, une entreprise, un lieu — est l'autre moitié, la difficile, et rien
* dans les lettres elles-mêmes ne le dit : « Boulanger » est un nom de famille
* autant qu'une enseigne, « Orange » une entreprise autant qu'un fruit, « Lyon »
* une ville autant qu'un patronyme.
*
* Ce niveau ne type donc un nom que lorsque la phrase le prouve. « M. » ou
* « Mme » devant prouve une personne ; une forme juridique comme « SARL » ou
* « Ltd » derrière, ou « la société » devant, prouve une entreprise. Tout le
* reste revient en `unknown`, qui est ce que la phrase autorise réellement.
* Deviner d'après la forme du mot, c'est ainsi que « Boulanger a livré le
* colis » devient une boulangerie.
*
* Deux limites sont comptées plutôt que cachées. Un mot capitalisé seul en tête
* de phrase n'est pas rendu, parce que la plupart ne sont que le premier mot de
* la phrase ; et un mot d'ouverture placé devant un vrai nom — « Après » dans
* « Après Renault » — ne fait pas partie du nom. `skipped_at_sentence_start`
* compte les deux. La liste de ces mots d'ouverture est déclarée, comme celle
* des civilités et celle des formes juridiques : un niveau qui devinerait
* lesquels sont des noms devinerait exactement ce qu'il annonce ne pas faire.
* Les articles en sont absents, sans quoi « Le Monde » perdrait son journal.
*
* Ce que ce niveau ne sait pas reconnaître du tout, c'est ce dont un nom est le
* nom : une référence de produit comme « A350 » dans « Airbus a livré son
* premier A350 » est capitalisée, ne suit aucun mot d'ouverture et ne porte
* aucun marqueur, donc elle revient comme un nom `unknown`. C'est le niveau
* au-dessus.
*
* Et les marqueurs ci-dessous sont français et anglais : c'est une liste
* déclarée, faite pour être étendue, et un texte qui n'en porte aucun revient
* entièrement `unknown`.
*/
// What proves a person, before the name.
export const HONORIFICS = new Set(['m', 'm.', 'mme', 'mlle', 'dr', 'dr.', 'me', 'pr',
'prof', 'prof.', 'mr', 'mr.', 'mrs', 'mrs.', 'ms', 'ms.', 'miss', 'sir']);
// What proves a company, after the name.
export const LEGAL_FORMS = new Set(['sarl', 'sas', 'sasu', 'sa', 'snc', 'sci', 'eurl',
'scop', 'gie', 'gmbh', 'ag', 'ltd', 'ltd.', 'inc', 'inc.', 'llc', 'plc', 'bv', 'nv', 'spa']);
// What proves a company, before the name. Two words at most, lowercase.
const ORG_LEADS = new Set(['la société', 'l entreprise', 'le groupe', 'la marque',
'the company', 'the firm', 'the group', 'l enseigne']);
// Lowercase words a name may contain without being cut: « Jean de La Fontaine »,
// « Ludwig van Beethoven », « Banque de France ». « et » and « and » are
// deliberately absent, although « Marks and Spencer » wants them: keeping them
// merges « Alex Ferguson et Acme » into a single name, and inventing one entity
// out of two is worse than returning one name in two pieces.
export const PARTICLES = new Set(['de', 'du', 'des', 'da', 'della', 'van', 'von',
'der', 'den', 'ten', 'of', 'la', 'le', 'les', 'el', 'al', 'd', 'l', 'o']);
// A word, with neither full stop nor apostrophe inside it. « M. » is therefore
// the token « M » followed by a separator, which keeps an honorific out of the
// name, and « L'enseigne » is two tokens, which keeps the article out of it.
const WORD = /\p{L}[\p{L}\p{N}_-]*/gu;
// Words that open a sentence and are never part of a name. A capitalised run
// that starts a sentence with one of them starts one word later: « Après
// Renault » is Renault, « Selon Le Monde » is Le Monde. The list is declared
// rather than inferred, like HONORIFICS and LEGAL_FORMS, because a rung that
// guessed which openers are names would be guessing exactly what it says it
// will not — and it is meant to be extended.
//
// Articles and particles are deliberately absent from it: « Le Monde » opens a
// sentence with « Le », and dropping that word would lose the newspaper.
export const SENTENCE_OPENERS = new Set([
'après', 'avant', 'selon', 'depuis', 'chez', 'pour', 'contre', 'malgré',
'dès', 'lors', 'entre', 'face', 'sans', 'sous', 'sur', 'dans', 'avec',
'par', 'vers', 'pendant', 'durant', 'outre', 'parmi', 'quant', 'comme',
'alors', 'ainsi', 'aussi', 'cependant', 'pourtant', 'toutefois', 'enfin',
'ensuite', 'puis', 'donc', 'mais', 'car', 'quand', 'lorsque',
'ce', 'cet', 'cette', 'ces', 'son', 'sa', 'ses', 'leur', 'leurs',
'notre', 'votre', 'nos', 'vos', 'mon', 'ma', 'mes',
'il', 'elle', 'ils', 'elles', 'on', 'nous', 'vous',
'after', 'before', 'since', 'according', 'despite', 'during', 'among',
'between', 'through', 'under', 'over', 'about', 'against', 'within',
'without', 'across', 'around', 'because', 'although', 'however',
'therefore', 'meanwhile', 'instead', 'finally', 'then', 'when', 'while',
'where', 'this', 'these', 'that', 'those', 'their', 'his', 'her', 'its',
'our', 'your', 'but', 'and', 'for', 'nor', 'yet', 'with', 'from',
]);
// What two words of one name may be separated by: a space, or the apostrophe
// French elides on — « Jean d'Artagnan » is one name.
const JOINS = /^([ \t\r\n\f\v ]+|['’])$/;
const SENTENCE_END = /[.!?…:;\n\r]\s*$/;
const UPPER = /^\p{Lu}/u;
/**
* The proper names of `text`, each with what the sentence proves about it.
*
* `type` is « person », « company » or « unknown », and `evidence` says what
* proved it. A name with no evidence is never typed by its spelling.
*
* `skipped_at_sentence_start` counts the capitalised words this rung set aside
* because they open a sentence: a word alone, which it cannot tell from a
* name, and the opener in front of a name — « Après » in « Après Renault ».
*
* What this rung has no way to recognise at all is what a name is *of*: a
* product reference like « A350 » in « Airbus a livré son premier A350 » is
* capitalised, follows no opener and carries no evidence, so it comes back as
* an `unknown` name. That is a rung above.
*/
export function extractNames(text) {
if (typeof text !== 'string') {
return { names: [], skipped_at_sentence_start: 0, reason: `expected text, not ${typeof text}` };
}
const tokens = [...text.matchAll(WORD)]
.map((m) => [m[0], m.index, m.index + m[0].length]);
const names = [];
let skipped = 0;
let index = 0;
while (index < tokens.length) {
const length = runLength(tokens, index, text);
if (length === 0) {
index += 1;
continue;
}
let start = tokens[index][1];
const end = tokens[index + length - 1][2];
const opens = index === 0 || SENTENCE_END.test(text.slice(tokens[index - 1][2], start));
if (opens && SENTENCE_OPENERS.has(tokens[index][0].toLowerCase())) {
// « Après Renault, … » — the name starts one word later, and the word
// set aside is counted rather than swallowed.
skipped += 1;
index += 1;
continue;
}
const [kind, evidence] = typed(tokens, index, length);
if (length > 1 && HONORIFICS.has(tokens[index][0].toLowerCase())) {
start = tokens[index + 1][1]; // « Mme » is not part of the name
}
const alone = tokens[index][0].toLowerCase();
const ordinary = PARTICLES.has(alone) || HONORIFICS.has(alone) || alone.length === 1;
if (length === 1 && opens && kind === 'unknown' && !ordinary) {
// « Lumière a livré le colis. » — most sentence openers are not names,
// and this rung has no way to tell which ones are. An opener that is
// plainly a determiner is not even counted.
skipped += 1;
} else if (!(length === 1 && ordinary)) {
names.push({ text: text.slice(start, end), type: kind, evidence, start, end });
}
index += length;
}
return { names, skipped_at_sentence_start: skipped, reason: null };
}
/** How many tokens from `index` belong to one capitalised run. */
function runLength(tokens, index, text) {
if (!UPPER.test(tokens[index][0])) return 0;
let length = 1;
let last = index;
while (last + 1 < tokens.length) {
const word = tokens[last + 1][0];
if (!JOINS.test(text.slice(tokens[last][2], tokens[last + 1][1]))) break;
if (UPPER.test(word)) {
last += 1;
} else if (PARTICLES.has(word.toLowerCase()) && last + 2 < tokens.length
&& UPPER.test(tokens[last + 2][0])) {
last += 2; // a particle only counts between two capitalised words
} else {
break;
}
length = last - index + 1;
}
return length;
}
/** What the sentence proves about this run, and the word that proves it. */
function typed(tokens, index, length) {
const before = tokens.slice(Math.max(0, index - 2), index).map((t) => t[0].toLowerCase());
const run = tokens.slice(index, index + length).map((t) => t[0].toLowerCase());
const after = index + length < tokens.length ? tokens[index + length][0].toLowerCase() : '';
if (before.length && HONORIFICS.has(before.at(-1))) return ['person', before.at(-1)];
if (length > 1 && HONORIFICS.has(run[0])) return ['person', run[0]];
if (LEGAL_FORMS.has(run.at(-1)) && length > 1) return ['company', run.at(-1)];
if (LEGAL_FORMS.has(after)) return ['company', after];
if (before.length === 2 && ORG_LEADS.has(before.join(' '))) {
// « l'entreprise » is two tokens; it is written back as one word.
return ['company', before.join(before[0].length === 1 ? "'" : ' ')];
}
return ['unknown', null];
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le texte est lu dans votre processus et n’est transmis à personne
- Une liste de noms de personnes est un traitement de données personnelles, quel que soit le niveau employé pour la produire
Point de rupture
Sans marqueur, la phrase ne prouve rien : dans « Le colis a été livré par Boulanger », le nom revient `unknown`, et c’est un nom de famille autant qu’une enseigne. Témoin dans le même test : la même phrase avec « la société » devant donne une entreprise, et avec « M. » devant, une personne.
Quand passer au niveau suivant
Vos textes ne portent pas ces marqueurs. La prose de presse nomme les gens et les entreprises sans jamais écrire « la société », et c’est le cas ordinaire : la part d’`unknown` dans le rapport vous le dira avant vous.
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Le modèle classique de ce problème est un champ aléatoire conditionnel sur des traits de mots — `sklearn-crfsuite` le fait —, et il demande un corpus annoté entité par entité dans la langue traitée. C’est précisément ce que vous n’avez pas : annoter quelques milliers de phrases coûte plus cher que tout le reste de cette fiche. Les pipelines du niveau au-dessus existent parce que cette annotation a été faite une fois, publiquement, et qu’elle est livrée avec eux.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Envoyer un article entier chez un tiers pour en rendre la liste des personnes qu’il cite, par document et sur un fonds qui se compte en milliers, est le cas où le coût et le périmètre réglementaire vont dans le même sens. Et le résultat n’est pas plus vérifiable qu’au niveau N2 : un modèle généraliste ajoute volontiers des noms qui ne sont pas dans le texte, là où le pipeline local rend des portions du document.
Pour aller plus loin
- spaCy — les pipelines français, leurs étiquettes et leur licence
- Transformers.js — faire tourner un modèle de reconnaissance d’entités dans Node
- sklearn-crfsuite — le champ aléatoire conditionnel, et le corpus annoté qu’il réclame
- Masquer les données personnelles dans un échange — la fiche voisine, quand le but est de les cacher et non de les lister
Source des chiffres
- spaCy, modèles français (consulté) : `fr_core_news_sm` 3.8.0, licence LGPL-LR, quatre étiquettes d’entités — LOC, MISC, ORG, PER
- spaCy sur PyPI (consulté) : version 3.8.16, publiée le 24 août 2026, licence MIT — celle de la bibliothèque, pas celle des modèles
- @huggingface/transformers, consulté sur le registre npm : version 4.3.0, publiée le 16 septembre 2026, licence Apache 2.0 ; c’est par lui qu’un pipeline de reconnaissance d’entités tourne dans Node
- compromise sur son dépôt (consulté) : « a considerate, common-sense baseline for english grammar » ; les variantes d’autres langues y sont décrites comme des travaux en cours, et `fr-compromise` en est à la version 0.3.1
- sklearn-crfsuite sur PyPI (consulté) : version 0.5.0, publiée le 18 juin 2024, licence MIT
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.