pyannotators-entityfishing 1.6.71__tar.gz → 1.6.77__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/PKG-INFO +1 -1
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/__init__.py +1 -1
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/.claude-plugin/plugin.json +1 -1
- pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/SKILL.md +112 -0
- pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/annotator.md +51 -0
- pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/importer.md +50 -0
- pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/processor.md +55 -0
- pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/types.md +61 -0
- pyannotators_entityfishing-1.6.77/tools/eval_linking.py +451 -0
- pyannotators_entityfishing-1.6.71/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -132
- pyannotators_entityfishing-1.6.71/submodules/python-archetype/skills/new-plugin/references/types.md +0 -37
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/.claude/skills/ef-mapping/SKILL.md +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/.gitignore +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/.gitmodules +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/.python-version +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/README.md +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/pyproject.toml +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/ef_client.py +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/entityfishing.py +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/HOWTO.md +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/README.md +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/resources/Stages.yml +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/resources/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/tools/check-marketplace.py +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/tools/el_jev.py +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/tools/eval_mappings.py +0 -0
- {pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/tools/wdfilter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyannotators-entityfishing
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.77
|
|
4
4
|
Summary: Annotator based on entity-fishing
|
|
5
5
|
Project-URL: Homepage, https://github.com/oterrier/pyannotators_entityfishing/
|
|
6
6
|
Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "python-archetype",
|
|
4
4
|
"displayName": "Kairntech python-archetype",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.1.0",
|
|
6
6
|
"description": "Cree et entretient les depots Python de Kairntech qui consomment python-archetype : creation d'un plugin a partir de sa graine, et mesure de la derive d'un depot par rapport a elle.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Kairntech",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: new-plugin
|
|
3
|
+
description: Créer un plugin Sherpa — importeur (pyimporters), processeur ou annotateur (pymultirole), et plus tard convertisseur, formateur ou segmenteur — à partir d'un format ou d'un besoin : clone la graine du contrat, écrit la classe et ses paramètres, les tests et leurs fixtures, la table du README, et câble le plugin dans le paquet de flavor du serveur. À utiliser quand on demande un nouvel importeur, un nouveau format de thésaurus, un nouveau processeur ou un nouvel annotateur, ou un nouveau plugin pyimporters / pymultirole.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Créer un plugin Sherpa
|
|
7
|
+
|
|
8
|
+
Tu fais **deux choses de nature différente**, et il ne faut pas les confondre :
|
|
9
|
+
|
|
10
|
+
- **l'enveloppe** (`pyproject.toml`, `Taskfile.yml`, `Jenkinsfile`, `.gitignore`…)
|
|
11
|
+
est produite par la graine et sa task `init`. **Tu ne l'écris pas, tu la lances.**
|
|
12
|
+
Elle doit rester identique aux dépôts frères à l'identifiant près ; la réécrire
|
|
13
|
+
de mémoire, c'est fabriquer un dépôt de plus subtilement différent.
|
|
14
|
+
- **le contenu** (la classe, ses paramètres, les tests, les fixtures, le README, le
|
|
15
|
+
câblage inter-dépôts) demande du jugement. C'est ton travail.
|
|
16
|
+
|
|
17
|
+
La méthode, ce qu'elle remplace et pourquoi : `md/PLUGIN_TEMPLATE.md` du poste,
|
|
18
|
+
ou le `README.md` de la graine.
|
|
19
|
+
|
|
20
|
+
## 1. Rassembler l'essentiel (demander, ne pas supposer)
|
|
21
|
+
|
|
22
|
+
- **le type** — voir [`references/types.md`](references/types.md) : la table type →
|
|
23
|
+
graine, groupe d'entry-points, classe de base, paquet de flavor, layout, patron de
|
|
24
|
+
description. Si le type demandé n'a pas encore de graine, **le dire et
|
|
25
|
+
s'arrêter** : ne pas improviser une enveloppe.
|
|
26
|
+
- **le nom** — en minuscules, sans préfixe : `foo` donne `pyprocessors_foo`,
|
|
27
|
+
`pyprocessors-foo`, et l'entry-point `foo`.
|
|
28
|
+
- **le préfixe de classe** — la casse est libre et suit le sujet, pas le nom :
|
|
29
|
+
`CSV`, `MeSH`, `Jev`, `EntityFishing`. Il donne les classes de la table des types
|
|
30
|
+
(`<CLASS>KnowledgeParser`, `<CLASS>Processor`, `<CLASS>Annotator`, et leurs
|
|
31
|
+
options ou paramètres).
|
|
32
|
+
- **la description** — une ligne, sur le patron des dépôts frères donné par la
|
|
33
|
+
table des types.
|
|
34
|
+
- **ce que demande le type** — chaque `references/<type>.md` liste ses propres
|
|
35
|
+
questions : un fichier d'exemple pour un importeur, un document réaliste pour un
|
|
36
|
+
processeur, un texte et ses annotations attendues pour un annotateur.
|
|
37
|
+
|
|
38
|
+
## 2. L'enveloppe : lancer la graine, ne rien inventer
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone git@bitbucket.org:kairntech/<prefixe>_seed.git <prefixe>_<nom>
|
|
42
|
+
cd <prefixe>_<nom>
|
|
43
|
+
# Le submodule d'ABORD : le Taskfile l'INCLUT, donc Task refuse de charger le
|
|
44
|
+
# fichier — et 'task init' n'existe pas — tant qu'il n'est pas initialisé.
|
|
45
|
+
git submodule update --init
|
|
46
|
+
task init NAME=<nom> CLASS=<CLASS> DESC="<description>"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`init` renomme le paquet, le module et le test, substitue l'identifiant, écrit la
|
|
50
|
+
description à ses trois emplacements, remet la version à `1.6.0`, retire le
|
|
51
|
+
`publish: false` du Jenkinsfile, coupe la section « graine » du README, se
|
|
52
|
+
supprime du Taskfile, et repointe `origin` en gardant `seed` en lecture seule.
|
|
53
|
+
|
|
54
|
+
Vérifier tout de suite, avant d'écrire une ligne de code :
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
task # doit sortir en 0 sur le squelette
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 3 à 5. Le contenu : la classe, les tests, le README
|
|
61
|
+
|
|
62
|
+
Lire **`references/<type>.md`** et le suivre : c'est la seule partie qui change
|
|
63
|
+
d'un type à l'autre.
|
|
64
|
+
|
|
65
|
+
- [`references/importer.md`](references/importer.md)
|
|
66
|
+
- [`references/processor.md`](references/processor.md)
|
|
67
|
+
- [`references/annotator.md`](references/annotator.md)
|
|
68
|
+
|
|
69
|
+
Deux règles valent pour tous les types :
|
|
70
|
+
|
|
71
|
+
- remplacer le corps de la classe de la graine, **garder sa forme** : la classe de
|
|
72
|
+
paramètres, la méthode du contrat, `get_model()` ;
|
|
73
|
+
- **garder le test de découverte par entry-point** que la graine fournit. Un plugin
|
|
74
|
+
dont l'entry-point est faux s'importe très bien, passe tous ses autres tests, et
|
|
75
|
+
n'est jamais appelé par le serveur.
|
|
76
|
+
|
|
77
|
+
## 6. Le câblage inter-dépôts — sans lui, le plugin n'existe pas
|
|
78
|
+
|
|
79
|
+
Le serveur découvre les plugins par entry-point, mais **seulement parmi les
|
|
80
|
+
paquets installés**, et ce qui est installé est la liste de dépendances d'un
|
|
81
|
+
paquet de flavor. Un plugin non câblé ne remonte jamais dans l'API.
|
|
82
|
+
|
|
83
|
+
- ajouter `"<distribution>>=1.6.0,<1.7.0"` aux `dependencies` du paquet de flavor
|
|
84
|
+
(`references/<type>.md` dit lequel) ;
|
|
85
|
+
- aligner la borne de `pyimporters_plugins` ou `pymultirole-plugins` sur celle des
|
|
86
|
+
dépôts frères, ne pas l'élargir ;
|
|
87
|
+
- le dépôt Bitbucket `kairntech/<prefixe>_<nom>` (**underscore** : seul le nom de
|
|
88
|
+
distribution prend le tiret) est créé par une personne, avec le script de
|
|
89
|
+
création de dépôt, qui crée aussi le webhook et le job Jenkins. Il demande des
|
|
90
|
+
identifiants : **ne pas le lancer soi-même**, le demander. Puis pousser, et
|
|
91
|
+
vérifier le premier build.
|
|
92
|
+
|
|
93
|
+
## 7. Vérifier, et ne rendre la main que vert
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
task # lint, tests, SBOM, CVE, build
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Corriger jusqu'au 0 — ruff compris, y compris le formatage des blocs ```python des
|
|
100
|
+
fichiers Markdown, que `ruff format` vérifie aussi. Ne pas déclarer terminé sur un
|
|
101
|
+
`task` rouge, et ne pas désactiver une règle pour faire passer le lint.
|
|
102
|
+
|
|
103
|
+
## Ce qu'il ne faut pas faire
|
|
104
|
+
|
|
105
|
+
- réécrire l'enveloppe à la main, ou « améliorer » le `pyproject.toml` : l'écart
|
|
106
|
+
avec les dépôts frères est le défaut, pas la variété ;
|
|
107
|
+
- toucher à `submodules/` : code épinglé, hors de notre autorité ;
|
|
108
|
+
- réciter l'ordre du pipeline de mémoire. Il vit dans `STAGES` et l'archétype
|
|
109
|
+
peut avoir bougé depuis l'installation de ce plugin : l'interroger
|
|
110
|
+
(`task stages`, `task --list`) ;
|
|
111
|
+
- versionner `uv.lock` (`LOCK_VERSIONED: "false"` ici : `py:sync` résout à neuf) ;
|
|
112
|
+
- écrire la version à la main : c'est la CI qui la pose.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Annotator — le contenu à écrire
|
|
2
|
+
|
|
3
|
+
Graine `kairntech/pyannotators_seed`, layout `src/pyannotators_<nom>/`, fichier
|
|
4
|
+
`src/pyannotators_<nom>/<nom>.py`. Modèle vivant : `pyannotators_entityfishing`
|
|
5
|
+
(enveloppe identique, service externe, mappings de labels).
|
|
6
|
+
|
|
7
|
+
## À demander en plus
|
|
8
|
+
|
|
9
|
+
- **les labels produits**, et d'où ils viennent : fixes, configurables, ou
|
|
10
|
+
calculés (les `mapped_labels` d'entityfishing) ;
|
|
11
|
+
- **un texte d'exemple** et les annotations qu'on en attend, offsets compris : c'est
|
|
12
|
+
la fixture des tests ;
|
|
13
|
+
- **un service externe ?** Son URL par défaut, la variable d'environnement qui la
|
|
14
|
+
surcharge, la clé et l'endroit où elle vit (`tests/.env`, jamais versionné).
|
|
15
|
+
|
|
16
|
+
## L'annotateur
|
|
17
|
+
|
|
18
|
+
Remplacer le corps de `src/pyannotators_<nom>/<nom>.py`, **garder la forme** :
|
|
19
|
+
|
|
20
|
+
- les paramètres dérivent de `AnnotatorParameters` et se déclarent en
|
|
21
|
+
`Field(défaut, description=...)` ; `json_schema_extra={"extra": ...}` comme pour
|
|
22
|
+
un processeur (`advanced`, `label`, `internal`…) ;
|
|
23
|
+
- `annotate(documents, parameters)` reçoit et rend **tout le lot**. Comme les
|
|
24
|
+
annotateurs frères, il écrit ses propres annotations dans `document.annotations` ;
|
|
25
|
+
- une `Annotation` a `start` et `end` en **offsets de caractères dans
|
|
26
|
+
`document.text`, fin exclue**, `labelName`, `text`, `score`, et ses liens vers une
|
|
27
|
+
base de connaissances dans `terms` (des `Term` : `identifier`, `lexicon`,
|
|
28
|
+
`preferredForm`, `properties`) ;
|
|
29
|
+
- `AnnotatorBase` a pour métaclasse `ABCSingleton` : même règle que pour un
|
|
30
|
+
processeur, rien dans `self` qui dépende d'un appel ;
|
|
31
|
+
- `get_model()` renvoie la classe de paramètres.
|
|
32
|
+
|
|
33
|
+
## Les tests
|
|
34
|
+
|
|
35
|
+
- le test du modèle et le test de découverte par entry-point de la graine, gardés ;
|
|
36
|
+
- un test d'annotation qui vérifie les **offsets exacts** et
|
|
37
|
+
`text[a.start:a.end] == a.text` pour chaque annotation : c'est ce qui attrape
|
|
38
|
+
un décalage d'un caractère ou une confusion octets/caractères ;
|
|
39
|
+
- un document sans occurrence, qui doit revenir avec une liste vide ;
|
|
40
|
+
- si l'annotateur appelle un service : pas de réseau par défaut (fixtures
|
|
41
|
+
enregistrées), tests live derrière un marqueur `integration`.
|
|
42
|
+
|
|
43
|
+
## Le README
|
|
44
|
+
|
|
45
|
+
Compléter la table des paramètres, et dire quels labels l'annotateur produit.
|
|
46
|
+
|
|
47
|
+
## Le câblage
|
|
48
|
+
|
|
49
|
+
`"pyannotators-<nom>>=1.6.0,<1.7.0"` dans les `dependencies` du paquet de flavor de
|
|
50
|
+
`pymultirole_server/packages/` : `pymultirole-builtins` pour un annotateur
|
|
51
|
+
générique, la flavor du client sinon (`references/types.md`).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Importer — le contenu à écrire
|
|
2
|
+
|
|
3
|
+
Graine `kairntech/pyimporters_seed`, paquet à plat `pyimporters_<nom>/`, fichier
|
|
4
|
+
`pyimporters_<nom>/<nom>.py`.
|
|
5
|
+
|
|
6
|
+
## À demander en plus
|
|
7
|
+
|
|
8
|
+
- **le format source** — un fichier d'exemple si possible, sinon la spec. Sans
|
|
9
|
+
échantillon, les options et le compte attendu des tests sont des devinettes :
|
|
10
|
+
demander.
|
|
11
|
+
|
|
12
|
+
## Le parser
|
|
13
|
+
|
|
14
|
+
Remplacer le corps de `pyimporters_<nom>/<nom>.py`, **garder la forme** :
|
|
15
|
+
|
|
16
|
+
- les options dérivent de `KnowledgeParserOptions` et se déclarent en
|
|
17
|
+
`Query(défaut, description=...)` — le schéma remonte dans l'API du serveur,
|
|
18
|
+
donc chaque `description` est de la documentation utilisateur, pas un commentaire ;
|
|
19
|
+
- `parse()` est un **générateur** qui `yield` des `Term` ; ne pas construire de liste ;
|
|
20
|
+
- `maybe_archive(source, encoding=...)` déplie `zip`/`gzip` de façon
|
|
21
|
+
transparente : s'en servir plutôt que d'ouvrir le fichier, et ajouter `"zip"`
|
|
22
|
+
aux extensions ;
|
|
23
|
+
- renseigner `bar.max` puis `bar.start()` quand le nombre de termes est
|
|
24
|
+
connaissable d'avance (une première passe de comptage est acceptable), sinon se
|
|
25
|
+
contenter de `bar.next()` ;
|
|
26
|
+
- `get_extensions()` liste les extensions **sans le point** : `["csv", "tsv", "zip"]`.
|
|
27
|
+
|
|
28
|
+
Un `Term` a `identifier` (obligatoire), `preferredForm`, `lexicon`, `properties`
|
|
29
|
+
(dict libre — c'est là que vont les formes alternatives, sous `altForms`).
|
|
30
|
+
|
|
31
|
+
## Les tests et leurs fixtures
|
|
32
|
+
|
|
33
|
+
- un test de parsing avec un **vrai fichier** dans `tests/data/`, et un
|
|
34
|
+
`check_expected()` qui vérifie un compte de concepts et un `Term` précis à un
|
|
35
|
+
index donné. Un compte exact vaut mieux qu'un `assert len(...) > 0` : c'est ce
|
|
36
|
+
qui attrape une régression de séparateur ou d'encodage.
|
|
37
|
+
- si le format se distribue en archive, un test sur le `.zip` — comme
|
|
38
|
+
`test_zipped_text` dans `pyimporters_csv`.
|
|
39
|
+
|
|
40
|
+
## Le README
|
|
41
|
+
|
|
42
|
+
Compléter la table des options : une ligne par option, avec son défaut et sa
|
|
43
|
+
description, **plus le comportement de repli** quand il n'est pas évident (dans
|
|
44
|
+
`pyimporters_csv`, un nom de colonne inconnu retombe sur la colonne 0 et
|
|
45
|
+
journalise un avertissement — ça se documente).
|
|
46
|
+
|
|
47
|
+
## Le câblage
|
|
48
|
+
|
|
49
|
+
`"pyimporters-<nom>>=1.6.0,<1.7.0"` dans les `dependencies` de
|
|
50
|
+
`pyimporters_server/packages/pyimporters-builtins/pyproject.toml`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Processor — le contenu à écrire
|
|
2
|
+
|
|
3
|
+
Graine `kairntech/pyprocessors_seed`, layout `src/pyprocessors_<nom>/`, fichier
|
|
4
|
+
`src/pyprocessors_<nom>/<nom>.py`. Modèles vivants : `pyprocessors_jev` (enveloppe
|
|
5
|
+
identique, appel HTTP, tests avec un `recorder`).
|
|
6
|
+
|
|
7
|
+
## À demander en plus
|
|
8
|
+
|
|
9
|
+
- **ce que le processeur lit et ce qu'il écrit** sur le `Document` : `text`,
|
|
10
|
+
`annotations` (et leurs `terms`), `categories`, `metadata`, `altTexts`,
|
|
11
|
+
`sentences`. Un processeur agit presque toujours sur ce qu'un plugin précédent
|
|
12
|
+
a produit : il faut un **document d'exemple réaliste**, annotations comprises,
|
|
13
|
+
sinon les tests vérifient un cas qui n'arrive jamais.
|
|
14
|
+
- **un service externe ?** Son URL par défaut, la variable d'environnement qui la
|
|
15
|
+
surcharge, la clé et l'endroit où elle vit (`tests/.env`, jamais versionné).
|
|
16
|
+
|
|
17
|
+
## Le processeur
|
|
18
|
+
|
|
19
|
+
Remplacer le corps de `src/pyprocessors_<nom>/<nom>.py`, **garder la forme** :
|
|
20
|
+
|
|
21
|
+
- les paramètres dérivent de `ProcessorParameters` et se déclarent en
|
|
22
|
+
`Field(défaut, description=...)` — le schéma remonte dans l'API de
|
|
23
|
+
`pymultirole_server`, donc chaque `description` est de la documentation
|
|
24
|
+
utilisateur. `json_schema_extra={"extra": ...}` pilote l'affichage dans Sherpa :
|
|
25
|
+
`advanced` (replié par défaut), `label` (sélecteur de label du projet),
|
|
26
|
+
`internal` (masqué), `multiline`… — reprendre les valeurs des dépôts frères ;
|
|
27
|
+
- `process(documents, parameters)` reçoit et rend **tout le lot** : modifier les
|
|
28
|
+
documents en place et les renvoyer, sans en perdre ni en réordonner. Un document
|
|
29
|
+
qui échoue ne doit pas faire perdre le lot (voir `process_one` dans
|
|
30
|
+
`pyprocessors_jev`) ;
|
|
31
|
+
- `ProcessorBase` a pour métaclasse `ABCSingleton` : une instance par classe. Ne
|
|
32
|
+
rien garder dans `self` qui dépende d'un appel ; un client HTTP se met en cache
|
|
33
|
+
par paramètres (`functools.cache`), pas en attribut ;
|
|
34
|
+
- `get_model()` renvoie la classe de paramètres.
|
|
35
|
+
|
|
36
|
+
## Les tests
|
|
37
|
+
|
|
38
|
+
- le test du modèle et le test de découverte par entry-point de la graine, gardés ;
|
|
39
|
+
- un test de traitement sur des `Document` construits dans le test, qui vérifie
|
|
40
|
+
**ce qui a été écrit** (labels, scores, offsets) et **ce qui n'a pas bougé** ;
|
|
41
|
+
- si le processeur appelle un service : aucun appel réseau par défaut. Remplacer
|
|
42
|
+
l'appel par un enregistreur (fixture `recorder`, comme `pyprocessors_jev`), et
|
|
43
|
+
mettre les tests live derrière un marqueur `integration`, exclu de `addopts`.
|
|
44
|
+
|
|
45
|
+
## Le README
|
|
46
|
+
|
|
47
|
+
Compléter la table des paramètres : une ligne par paramètre, avec son défaut et sa
|
|
48
|
+
description, plus le comportement de repli (un service injoignable, un document
|
|
49
|
+
sans annotation…).
|
|
50
|
+
|
|
51
|
+
## Le câblage
|
|
52
|
+
|
|
53
|
+
`"pyprocessors-<nom>>=1.6.0,<1.7.0"` dans les `dependencies` du paquet de flavor de
|
|
54
|
+
`pymultirole_server/packages/` : `pymultirole-builtins` pour un processeur
|
|
55
|
+
générique, la flavor du client sinon (`references/types.md`).
|
pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/types.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Table des types de plugins
|
|
2
|
+
|
|
3
|
+
Ce qui distingue un type d'un autre est **de la donnée** — quelques colonnes. C'est
|
|
4
|
+
la raison pour laquelle il y a *un* skill `new-plugin` et non un par famille :
|
|
5
|
+
six skills à 90 % identiques dériveraient les uns des autres, et rien ne lint un
|
|
6
|
+
skill.
|
|
7
|
+
|
|
8
|
+
| type | graine | groupe d'entry-points | classe de base | paquet de flavor | graine prête ? |
|
|
9
|
+
|------|--------|-----------------------|----------------|------------------|----------------|
|
|
10
|
+
| `importer` | `kairntech/pyimporters_seed` | `pyimporters.plugins` | `KnowledgeParserBase` (`pyimporters_plugins.base`) | `pyimporters_server/packages/pyimporters-builtins` | **oui** |
|
|
11
|
+
| `converter` | `kairntech/pyconverters_seed` | `pyconverters.plugins` | `ConverterBase` (`pymultirole_plugins.v1.converter`) | `pymultirole_server/packages/pymultirole-builtins` | non |
|
|
12
|
+
| `processor` | `kairntech/pyprocessors_seed` | `pyprocessors.plugins` | `ProcessorBase` (`pymultirole_plugins.v1.processor`) | `pymultirole-builtins` ou une flavor dédiée | **oui** |
|
|
13
|
+
| `annotator` | `kairntech/pyannotators_seed` | `pyannotators.plugins` | `AnnotatorBase` (`pymultirole_plugins.v1.annotator`) | `pymultirole-builtins` ou une flavor dédiée | **oui** |
|
|
14
|
+
| `formatter` | `kairntech/pyformatters_seed` | `pyformatters.plugins` | `FormatterBase` (`pymultirole_plugins.v1.formatter`) | `pymultirole-builtins` | non |
|
|
15
|
+
| `segmenter` | `kairntech/pysegmenters_seed` | `pysegmenters.plugins` | `SegmenterBase` (`pymultirole_plugins.v1.segmenter`) | `pymultirole-builtins` | non |
|
|
16
|
+
|
|
17
|
+
## Ce que chaque graine prête produit
|
|
18
|
+
|
|
19
|
+
| type | layout | `MODULE` du Taskfile | `CLASS` (exemple) → classes produites | patron de `DESC` | contenu à écrire |
|
|
20
|
+
|------|--------|----------------------|----------------------------------------|------------------|------------------|
|
|
21
|
+
| `importer` | à plat : `pyimporters_<nom>/` | `pyimporters_<nom>` | `CSV` → `CSVKnowledgeParser`, `CSVOptions` | « Sherpa FOO knowledge import plugin » | [`importer.md`](importer.md) |
|
|
22
|
+
| `processor` | `src/pyprocessors_<nom>/` | `src/pyprocessors_<nom>` | `Foo` → `FooProcessor`, `FooParameters` | « Processor based on foo » | [`processor.md`](processor.md) |
|
|
23
|
+
| `annotator` | `src/pyannotators_<nom>/` | `src/pyannotators_<nom>` | `Foo` → `FooAnnotator`, `FooParameters` | « Annotator based on foo » | [`annotator.md`](annotator.md) |
|
|
24
|
+
|
|
25
|
+
Le layout suit la famille, pas une préférence : les dépôts `pyimporters_*` ont leur
|
|
26
|
+
paquet à plat, les dépôts `pymultirole` sont en `src/` — dont `pyprocessors_jev` et
|
|
27
|
+
`pyannotators_entityfishing`, déjà passés sous `python-archetype`, sur lesquels les
|
|
28
|
+
graines `pymultirole` sont calquées (ruff à 256 colonnes compris). Après `task init`,
|
|
29
|
+
le Taskfile d'un processeur est identique octet pour octet à celui de
|
|
30
|
+
`pyprocessors_jev`, celui d'un annotateur à celui de `pyannotators_entityfishing`.
|
|
31
|
+
|
|
32
|
+
Les paquets de flavor de `pymultirole_server/packages/` : `pymultirole-builtins`,
|
|
33
|
+
`pymultirole-afp`, `pymultirole-cairn`, `pymultirole-escrim`, `pymultirole-grouperf`,
|
|
34
|
+
`pymultirole-ner`, `pymultirole-scai`, `pymultirole-xcago` (`pymultirole-app` est le
|
|
35
|
+
serveur, pas une flavor). Un plugin propre à un client va dans la flavor de ce
|
|
36
|
+
client ; demander, ne pas deviner.
|
|
37
|
+
|
|
38
|
+
## Trois graines existent aujourd'hui
|
|
39
|
+
|
|
40
|
+
`pyimporters_seed`, `pyprocessors_seed` et `pyannotators_seed` sont poussées, et ne
|
|
41
|
+
publient rien (`publish: false`). **Les trois autres n'existent pas encore.** La
|
|
42
|
+
famille `pymultirole` passe progressivement sous `python-archetype` : quelques dépôts
|
|
43
|
+
sont migrés, la plupart portent encore la sortie de `cookiecutter-pymultirole`
|
|
44
|
+
(`bumpversion.py`, `AUTHORS.md`, `CHANGELOG.md`, `RELEASE.md`, `mypy.ini`, `docs/`).
|
|
45
|
+
|
|
46
|
+
Si on demande un type dont la graine n'existe pas : **le dire et s'arrêter.**
|
|
47
|
+
Improviser l'enveloppe reviendrait à recréer à la main ce que la graine garantit,
|
|
48
|
+
sans la garantie — et le dépôt produit devrait être migré comme les autres. La
|
|
49
|
+
bonne réponse est de créer d'abord la graine du type, sur le modèle des trois
|
|
50
|
+
existantes.
|
|
51
|
+
|
|
52
|
+
## Pourquoi une graine par contrat, et un seul skill
|
|
53
|
+
|
|
54
|
+
Une graine doit **builder** : un `ConverterBase` et un `ProcessorBase` n'ont pas
|
|
55
|
+
la même signature, donc un dépôt ne peut pas builder les deux. Une graine
|
|
56
|
+
paramétrée qui supprimerait quatre contrats sur cinq à l'`init` redeviendrait un
|
|
57
|
+
template — du code non exécutable, hors CI, exactement le défaut qu'on corrige.
|
|
58
|
+
|
|
59
|
+
Un skill, lui, n'a rien à builder : le dupliquer ne coûte pas un job Jenkins, ça
|
|
60
|
+
coûte une divergence que personne ne mesure. D'où un corps unique (`SKILL.md`) et
|
|
61
|
+
une référence par type pour ce qui change vraiment : le contenu à écrire.
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Evaluate entity linking on gold corpora: entity-fishing /disambiguate vs term lookup + Jev.
|
|
3
|
+
|
|
4
|
+
SHERPA-3037, the evaluation step of ADR-0001 (docs/adr/0001-linking-candidats-entityfishing-decision-jev.md).
|
|
5
|
+
|
|
6
|
+
The gold corpora are MSNBC and AQUAINT from the entity-fishing repository
|
|
7
|
+
(data/corpus/corpus-long): news texts where every mention carries its offset and the
|
|
8
|
+
expected Wikipedia title (empty = NIL, an entity with no page). They are downloaded
|
|
9
|
+
into .cache/linking/ and never copied into this repository. Gold titles are resolved to
|
|
10
|
+
Wikidata QIDs through the Wikipedia API, redirects included.
|
|
11
|
+
|
|
12
|
+
The gold mention spans stand in for the NER: every system is scored on the same
|
|
13
|
+
mentions. The corpora carry no NER type, so the Jev chain runs WITHOUT the type
|
|
14
|
+
filter of the mappings.
|
|
15
|
+
|
|
16
|
+
Systems
|
|
17
|
+
-------
|
|
18
|
+
disambiguate entity-fishing /disambiguate on the whole text, as the annotator calls it
|
|
19
|
+
prior the most common sense: the best prob_c of the term lookup
|
|
20
|
+
jev term lookup (top K) + one Jev Choice per distinct surface, one call per document
|
|
21
|
+
|
|
22
|
+
.venv/bin/python tools/eval_linking.py --corpus msnbc --limit 3 # smoke run
|
|
23
|
+
.venv/bin/python tools/eval_linking.py # both corpora
|
|
24
|
+
.venv/bin/python tools/eval_linking.py --bench 8 # latency and throughput, real calls
|
|
25
|
+
|
|
26
|
+
Jev answers are cached per (model, document) in .cache/linking/jev.json, so a re-run
|
|
27
|
+
costs nothing; --refresh-jev forces new calls. The key is read from JEV_API_KEY or
|
|
28
|
+
TYPESAFE_API_KEY (tests/.env is loaded if present).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
import time
|
|
39
|
+
import xml.etree.ElementTree as ET
|
|
40
|
+
from collections import Counter
|
|
41
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import requests
|
|
46
|
+
|
|
47
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
48
|
+
CACHE = ROOT / ".cache" / "linking"
|
|
49
|
+
RAW = "https://raw.githubusercontent.com/kermitt2/entity-fishing/master/data/corpus/corpus-long"
|
|
50
|
+
EF_DEFAULT = "https://entityfishing.kairntech.com"
|
|
51
|
+
JEV_URL = "https://api.typesafe.ai/v1/systemone"
|
|
52
|
+
WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php"
|
|
53
|
+
USER_AGENT = "kairntech-eval-linking/0.1 (olivier.terrier@kairntech.com)"
|
|
54
|
+
NONE = "NONE"
|
|
55
|
+
THRESHOLDS = [0.0, 0.5, 0.7, 0.9]
|
|
56
|
+
|
|
57
|
+
session = requests.Session()
|
|
58
|
+
session.headers["User-Agent"] = USER_AGENT
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- cache
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cached(name: str) -> dict:
|
|
65
|
+
path = CACHE / name
|
|
66
|
+
return json.loads(path.read_text()) if path.exists() else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(name: str, data: dict) -> None:
|
|
70
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
(CACHE / name).write_text(json.dumps(data, ensure_ascii=False))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- corpus
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Mention:
|
|
79
|
+
start: int
|
|
80
|
+
end: int
|
|
81
|
+
surface: str
|
|
82
|
+
title: str # expected Wikipedia title, "" for NIL
|
|
83
|
+
qid: str | None = None # resolved gold QID, None for NIL or unresolved
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Doc:
|
|
88
|
+
corpus: str
|
|
89
|
+
name: str
|
|
90
|
+
text: str
|
|
91
|
+
mentions: list[Mention] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def fetch_text(url: str, path: Path) -> str:
|
|
95
|
+
if not path.exists():
|
|
96
|
+
resp = session.get(url, timeout=60)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
path.write_bytes(resp.content)
|
|
100
|
+
return path.read_text(encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_corpus(corpus: str) -> list[Doc]:
|
|
104
|
+
xml = fetch_text(f"{RAW}/{corpus}/{corpus}.xml", CACHE / corpus / f"{corpus}.xml")
|
|
105
|
+
docs = []
|
|
106
|
+
for d in ET.fromstring(xml).findall("document"):
|
|
107
|
+
name = d.get("docName")
|
|
108
|
+
text = fetch_text(f"{RAW}/{corpus}/RawText/{name}", CACHE / corpus / "RawText" / name)
|
|
109
|
+
doc = Doc(corpus, name, text)
|
|
110
|
+
for a in d.findall("annotation"):
|
|
111
|
+
start, length = int(a.findtext("offset")), int(a.findtext("length"))
|
|
112
|
+
surface = a.findtext("mention") or ""
|
|
113
|
+
if text[start : start + length] != surface:
|
|
114
|
+
continue # one misaligned gold offset in MSNBC: skipped, not guessed
|
|
115
|
+
doc.mentions.append(Mention(start, start + length, surface, (a.findtext("wikiName") or "").strip()))
|
|
116
|
+
docs.append(doc)
|
|
117
|
+
return docs
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_titles(docs: list[Doc]) -> None:
|
|
121
|
+
"""Gold Wikipedia titles → Wikidata QIDs, following redirects (the corpora date from 2007-2010)."""
|
|
122
|
+
cache = cached("titles.json")
|
|
123
|
+
titles = sorted({m.title for d in docs for m in d.mentions if m.title and m.title not in cache})
|
|
124
|
+
for i in range(0, len(titles), 50):
|
|
125
|
+
batch = titles[i : i + 50]
|
|
126
|
+
resp = session.get(
|
|
127
|
+
WIKIPEDIA_API,
|
|
128
|
+
params={"action": "query", "titles": "|".join(batch), "redirects": 1, "prop": "pageprops", "ppprop": "wikibase_item", "format": "json", "formatversion": 2},
|
|
129
|
+
timeout=60,
|
|
130
|
+
)
|
|
131
|
+
query = resp.json()["query"]
|
|
132
|
+
rename = {n["from"]: n["to"] for n in query.get("normalized", [])}
|
|
133
|
+
rename.update({r["from"]: r["to"] for r in query.get("redirects", [])})
|
|
134
|
+
qid_of = {p["title"]: p.get("pageprops", {}).get("wikibase_item") for p in query["pages"]}
|
|
135
|
+
for title in batch:
|
|
136
|
+
final = title
|
|
137
|
+
for _ in range(3): # normalized, then redirected
|
|
138
|
+
final = rename.get(final, final)
|
|
139
|
+
cache[title] = qid_of.get(final)
|
|
140
|
+
save("titles.json", cache)
|
|
141
|
+
for d in docs:
|
|
142
|
+
for m in d.mentions:
|
|
143
|
+
m.qid = cache.get(m.title) if m.title else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------------------- entity-fishing
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def disambiguate(doc: Doc, ef: str, min_selector: float) -> dict[tuple[int, int], str]:
|
|
150
|
+
cache = cached("disambiguate.json")
|
|
151
|
+
key = f"{doc.corpus}/{doc.name}/{min_selector}"
|
|
152
|
+
if key not in cache:
|
|
153
|
+
query = {"text": doc.text, "language": {"lang": "en"}, "minSelectorScore": min_selector}
|
|
154
|
+
resp = session.post(f"{ef}/service/disambiguate", files={"query": (None, json.dumps(query))}, timeout=300)
|
|
155
|
+
entities = resp.json().get("entities", []) if resp.ok else []
|
|
156
|
+
cache[key] = [[e["offsetStart"], e["offsetEnd"], e["wikidataId"]] for e in entities if e.get("wikidataId")]
|
|
157
|
+
save("disambiguate.json", cache)
|
|
158
|
+
return {(s, e): q for s, e, q in cache[key]}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch_term(s: str, ef: str) -> tuple[str, list[dict]]:
|
|
162
|
+
resp = session.get(f"{ef}/service/kb/term/{requests.utils.quote(s, safe='')}", params={"lang": "en"}, timeout=60)
|
|
163
|
+
return s, (resp.json().get("senses", []) if resp.ok else [])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def term_lookup(surfaces: list[str], ef: str) -> dict[str, list[dict]]:
|
|
167
|
+
cache = cached("terms.json")
|
|
168
|
+
missing = [s for s in surfaces if s not in cache]
|
|
169
|
+
with ThreadPoolExecutor(8) as pool:
|
|
170
|
+
cache.update(dict(pool.map(lambda s: fetch_term(s, ef), missing)))
|
|
171
|
+
if missing:
|
|
172
|
+
save("terms.json", cache)
|
|
173
|
+
return {s: cache[s] for s in surfaces}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def fetch_concept(p: str, ef: str) -> tuple[str, dict]:
|
|
177
|
+
resp = session.get(f"{ef}/service/kb/concept/{p}", params={"lang": "en"}, timeout=60)
|
|
178
|
+
c = resp.json() if resp.ok else {}
|
|
179
|
+
facts = {}
|
|
180
|
+
for st in c.get("statements", []):
|
|
181
|
+
if st.get("propertyId") in ("P31", "P106", "P27", "P17", "P641", "P159") and st.get("valueName"):
|
|
182
|
+
facts.setdefault(st["propertyName"], []).append(st["valueName"])
|
|
183
|
+
defs = c.get("definitions") or [{}]
|
|
184
|
+
return p, {"qid": c.get("wikidataId"), "definition": first_sentence(defs[0].get("definition", "")), "facts": {k: v[:3] for k, v in facts.items()}}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def concepts(pageids: list[int], ef: str) -> dict[str, dict]:
|
|
188
|
+
cache = cached("concepts.json")
|
|
189
|
+
missing = sorted({str(p) for p in pageids} - set(cache))
|
|
190
|
+
with ThreadPoolExecutor(8) as pool:
|
|
191
|
+
cache.update(dict(pool.map(lambda p: fetch_concept(p, ef), missing)))
|
|
192
|
+
if missing:
|
|
193
|
+
save("concepts.json", cache)
|
|
194
|
+
return {str(p): cache[str(p)] for p in pageids}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def first_sentence(wikitext: str) -> str:
|
|
198
|
+
t = re.sub(r"\[\[(?:[^|\]]*\|)?([^\]]*)\]\]", r"\1", wikitext)
|
|
199
|
+
t = re.sub(r"'''?|\{\{[^}]*\}\}|\([^)]*\)", "", t)
|
|
200
|
+
return re.split(r"(?<=\.)\s", re.sub(r"\s+", " ", t).strip())[0][:300]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------- Jev
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def candidates_of(doc: Doc, k: int, ef: str) -> dict[str, list[dict]]:
|
|
207
|
+
"""Distinct surface → its top-K candidates (qid, title, prior, description)."""
|
|
208
|
+
surfaces = sorted({m.surface for m in doc.mentions})
|
|
209
|
+
senses = term_lookup(surfaces, ef)
|
|
210
|
+
kept = {s: senses[s][:k] for s in surfaces}
|
|
211
|
+
return assemble(kept, concepts([sense["pageid"] for ss in kept.values() for sense in ss], ef))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def fresh_candidates(doc: Doc, k: int, ef: str) -> dict[str, list[dict]]:
|
|
215
|
+
"""Same as candidates_of, with no cache: what the production chain pays per document."""
|
|
216
|
+
surfaces = sorted({m.surface for m in doc.mentions})
|
|
217
|
+
with ThreadPoolExecutor(8) as pool:
|
|
218
|
+
kept = {s: ss[:k] for s, ss in pool.map(lambda s: fetch_term(s, ef), surfaces)}
|
|
219
|
+
pageids = sorted({str(sense["pageid"]) for ss in kept.values() for sense in ss})
|
|
220
|
+
info = dict(pool.map(lambda p: fetch_concept(p, ef), pageids))
|
|
221
|
+
return assemble(kept, info)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def assemble(kept: dict[str, list[dict]], info: dict[str, dict]) -> dict[str, list[dict]]:
|
|
225
|
+
out = {}
|
|
226
|
+
for s, ss in kept.items():
|
|
227
|
+
cands, seen = [], set()
|
|
228
|
+
for sense in ss:
|
|
229
|
+
c = info[str(sense["pageid"])]
|
|
230
|
+
if c["qid"] and c["qid"] not in seen:
|
|
231
|
+
seen.add(c["qid"])
|
|
232
|
+
cands.append({"qid": c["qid"], "title": sense["preferred"], "prior": sense["prob_c"], "definition": c["definition"], "facts": c["facts"]})
|
|
233
|
+
out[s] = cands
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_questions(cands: dict[str, list[dict]]) -> tuple[list[str], dict]:
|
|
238
|
+
surfaces = [s for s in sorted(cands) if cands[s]]
|
|
239
|
+
questions = {}
|
|
240
|
+
for i, s in enumerate(surfaces):
|
|
241
|
+
criteria = {c["qid"]: {"name": c["title"], "description": c["definition"], "facts": c["facts"]} for c in cands[s]}
|
|
242
|
+
criteria[NONE] = "None of the listed entities is the one meant by the mention"
|
|
243
|
+
questions[f"m{i}"] = {
|
|
244
|
+
"type": "choice",
|
|
245
|
+
"instructions": {"mention": s, "question": "In `text`, which knowledge-base entity does `mention` refer to?"},
|
|
246
|
+
"criteria": criteria,
|
|
247
|
+
}
|
|
248
|
+
return surfaces, questions
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def call_jev(text: str, questions: dict, model: str, key: str) -> dict:
|
|
252
|
+
resp = session.post(JEV_URL, json={"model": model, "state": {"text": text}, "questions": questions}, headers={"Authorization": f"Bearer {key}"}, timeout=300)
|
|
253
|
+
resp.raise_for_status()
|
|
254
|
+
return resp.json()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def jev(doc: Doc, cands: dict[str, list[dict]], model: str, key: str, refresh: bool) -> tuple[dict, dict]:
|
|
258
|
+
cache = cached("jev.json")
|
|
259
|
+
ck = f"{model}/{doc.corpus}/{doc.name}"
|
|
260
|
+
if ck in cache and not refresh:
|
|
261
|
+
return cache[ck]["answers"], cache[ck]["usage"]
|
|
262
|
+
surfaces, questions = build_questions(cands)
|
|
263
|
+
if not questions:
|
|
264
|
+
return {}, {}
|
|
265
|
+
result = call_jev(doc.text, questions, model, key)
|
|
266
|
+
answers = {surfaces[int(q[1:])]: a for q, a in result["answers"].items()}
|
|
267
|
+
cache[ck] = {"answers": answers, "usage": result.get("usage", {})}
|
|
268
|
+
save("jev.json", cache)
|
|
269
|
+
return answers, result.get("usage", {})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------- scoring
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class Score:
|
|
277
|
+
linked: int = 0 # non-NIL gold mentions that got a link
|
|
278
|
+
correct: int = 0
|
|
279
|
+
gold: int = 0 # non-NIL gold mentions with a resolved QID
|
|
280
|
+
nil: int = 0
|
|
281
|
+
nil_abstained: int = 0
|
|
282
|
+
|
|
283
|
+
def add(self, m: Mention, predicted: str | None) -> None:
|
|
284
|
+
if m.title == "":
|
|
285
|
+
self.nil += 1
|
|
286
|
+
self.nil_abstained += predicted is None
|
|
287
|
+
return
|
|
288
|
+
self.gold += 1
|
|
289
|
+
if predicted is not None:
|
|
290
|
+
self.linked += 1
|
|
291
|
+
self.correct += predicted == m.qid
|
|
292
|
+
|
|
293
|
+
def row(self) -> str:
|
|
294
|
+
p = self.correct / self.linked if self.linked else 0.0
|
|
295
|
+
r = self.correct / self.gold if self.gold else 0.0
|
|
296
|
+
f = 2 * p * r / (p + r) if p + r else 0.0
|
|
297
|
+
nil = f"{self.nil_abstained}/{self.nil}" if self.nil else "-"
|
|
298
|
+
return f"P {p:6.1%} R {r:6.1%} F1 {f:6.1%} (correct {self.correct}/{self.gold}, linked {self.linked}) NIL abstained {nil}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --------------------------------------------------------------------------- bench
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def stats(values: list[float]) -> str:
|
|
305
|
+
v = sorted(values)
|
|
306
|
+
p95 = v[min(len(v) - 1, round(0.95 * (len(v) - 1)))]
|
|
307
|
+
return f"mean {sum(v) / len(v):6.2f} s median {v[len(v) // 2]:6.2f} s p95 {p95:6.2f} s"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def bench(docs: list[Doc], args, key: str) -> int:
|
|
311
|
+
"""Time the real calls, no cache: per-stage latency, then Jev throughput at several concurrencies."""
|
|
312
|
+
per_corpus = max(1, args.bench // 2)
|
|
313
|
+
sample = [d for c in ("msnbc", "aquaint") for d in [x for x in docs if x.corpus == c][:per_corpus]]
|
|
314
|
+
timings: dict[str, list[float]] = {"disambiguate": [], "kb concepts": [], "candidates": [], "jev": []}
|
|
315
|
+
n_qids = []
|
|
316
|
+
usage, prepared = Counter(), []
|
|
317
|
+
for n, doc in enumerate(sample, 1):
|
|
318
|
+
print(f"# latency {n}/{len(sample)} {doc.corpus}/{doc.name}", file=sys.stderr)
|
|
319
|
+
t = time.perf_counter()
|
|
320
|
+
query = {"text": doc.text, "language": {"lang": "en"}, "minSelectorScore": args.min_selector}
|
|
321
|
+
resp = session.post(f"{args.ef}/service/disambiguate", files={"query": (None, json.dumps(query))}, timeout=300)
|
|
322
|
+
timings["disambiguate"].append(time.perf_counter() - t)
|
|
323
|
+
# What pyannotators_entityfishing does next (get_kb_concepts): one /kb/concept per
|
|
324
|
+
# distinct QID, on a pool of 8 connections, to apply the mappings and build the terms.
|
|
325
|
+
qids = sorted({e["wikidataId"] for e in resp.json().get("entities", []) if e.get("wikidataId")})
|
|
326
|
+
n_qids.append(len(qids))
|
|
327
|
+
t = time.perf_counter()
|
|
328
|
+
with ThreadPoolExecutor(8) as pool:
|
|
329
|
+
list(pool.map(lambda q: session.get(f"{args.ef}/service/kb/concept/{q}", timeout=60), qids))
|
|
330
|
+
timings["kb concepts"].append(time.perf_counter() - t)
|
|
331
|
+
t = time.perf_counter()
|
|
332
|
+
cands = fresh_candidates(doc, args.k, args.ef)
|
|
333
|
+
timings["candidates"].append(time.perf_counter() - t)
|
|
334
|
+
_, questions = build_questions(cands)
|
|
335
|
+
prepared.append((doc, questions))
|
|
336
|
+
if args.no_jev:
|
|
337
|
+
continue
|
|
338
|
+
t = time.perf_counter()
|
|
339
|
+
result = call_jev(doc.text, questions, args.model, key)
|
|
340
|
+
timings["jev"].append(time.perf_counter() - t)
|
|
341
|
+
usage.update(result.get("usage", {}))
|
|
342
|
+
|
|
343
|
+
words = [len(d.text.split()) for d in sample]
|
|
344
|
+
questions = [len(q) for _, q in prepared]
|
|
345
|
+
print(f"\n{len(sample)} documents: {sum(words) / len(words):.0f} words, {sum(n_qids) / len(n_qids):.0f} distinct QIDs from /disambiguate and {sum(questions) / len(questions):.0f} Jev questions per document on average")
|
|
346
|
+
if not args.no_jev:
|
|
347
|
+
print(f"Jev tokens: {usage['input_tokens'] / len(sample):.0f} input, {usage['output_tokens'] / len(sample):.0f} output per document")
|
|
348
|
+
print("\nLatency per document, calls in sequence:")
|
|
349
|
+
for stage, values in timings.items():
|
|
350
|
+
if values:
|
|
351
|
+
print(f" {stage:13} {stats(values)}")
|
|
352
|
+
current = [d + k for d, k in zip(timings["disambiguate"], timings["kb concepts"], strict=True)]
|
|
353
|
+
print(f" {'CURRENT':13} {stats(current)} (disambiguate + kb concepts, as pyannotators_entityfishing)")
|
|
354
|
+
if args.no_jev:
|
|
355
|
+
print(f" {'candidates':13} (the new chain adds its Jev call to this; skipped by --no-jev)")
|
|
356
|
+
return 0
|
|
357
|
+
chain = [c + j for c, j in zip(timings["candidates"], timings["jev"], strict=True)]
|
|
358
|
+
print(f" {'NEW':13} {stats(chain)} (candidates + jev)")
|
|
359
|
+
|
|
360
|
+
print("\nJev throughput (same documents):")
|
|
361
|
+
print(f" concurrency 1: {len(sample) / sum(timings['jev']):.2f} documents/s, {usage['input_tokens'] / sum(timings['jev']):.0f} input tokens/s")
|
|
362
|
+
for c in args.concurrency:
|
|
363
|
+
if c == 1:
|
|
364
|
+
continue
|
|
365
|
+
t = time.perf_counter()
|
|
366
|
+
with ThreadPoolExecutor(c) as pool:
|
|
367
|
+
results = list(pool.map(lambda dq: call_jev(dq[0].text, dq[1], args.model, key), prepared))
|
|
368
|
+
wall = time.perf_counter() - t
|
|
369
|
+
tokens = sum(r.get("usage", {}).get("input_tokens", 0) for r in results)
|
|
370
|
+
print(f" concurrency {c:2}: {len(sample) / wall:.2f} documents/s, {tokens / wall:.0f} input tokens/s (wall {wall:.1f} s)")
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main() -> int:
|
|
375
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
376
|
+
parser.add_argument("--corpus", action="append", choices=["msnbc", "aquaint"], help="default: both")
|
|
377
|
+
parser.add_argument("--limit", type=int, default=0, help="first N documents of each corpus (smoke run)")
|
|
378
|
+
parser.add_argument("--ef", default=EF_DEFAULT)
|
|
379
|
+
parser.add_argument("--min-selector", type=float, default=0.3, help="minSelectorScore of /disambiguate (annotator default: 0.3)")
|
|
380
|
+
parser.add_argument("--k", type=int, default=8, help="candidates kept per mention")
|
|
381
|
+
parser.add_argument("--model", default="jev-latest")
|
|
382
|
+
parser.add_argument("--refresh-jev", action="store_true")
|
|
383
|
+
parser.add_argument("--no-jev", action="store_true", help="skip the Jev calls (candidates and baselines only; with --bench, time entity-fishing only)")
|
|
384
|
+
parser.add_argument("--bench", type=int, default=0, metavar="N", help="time real calls (no cache) on N documents, half from each corpus")
|
|
385
|
+
parser.add_argument("--concurrency", type=int, nargs="+", default=[1, 8], help="Jev concurrencies measured by --bench")
|
|
386
|
+
args = parser.parse_args()
|
|
387
|
+
|
|
388
|
+
env = ROOT / "tests" / ".env"
|
|
389
|
+
if env.exists():
|
|
390
|
+
for line in env.read_text().splitlines():
|
|
391
|
+
if "=" in line and not line.lstrip().startswith("#"):
|
|
392
|
+
k, v = line.split("=", 1)
|
|
393
|
+
os.environ.setdefault(k.strip(), v.strip().strip("'\""))
|
|
394
|
+
key = os.getenv("JEV_API_KEY") or os.getenv("TYPESAFE_API_KEY")
|
|
395
|
+
if not key and not args.no_jev:
|
|
396
|
+
print("No JEV_API_KEY: use --no-jev", file=sys.stderr)
|
|
397
|
+
return 1
|
|
398
|
+
|
|
399
|
+
docs = []
|
|
400
|
+
for corpus in args.corpus or ["msnbc", "aquaint"]:
|
|
401
|
+
docs += load_corpus(corpus)[: args.limit or None]
|
|
402
|
+
if args.bench:
|
|
403
|
+
return bench(docs, args, key)
|
|
404
|
+
resolve_titles(docs)
|
|
405
|
+
unresolved = sum(1 for d in docs for m in d.mentions if m.title and not m.qid)
|
|
406
|
+
for d in docs: # a gold title with no QID cannot be scored either way
|
|
407
|
+
d.mentions = [m for m in d.mentions if not m.title or m.qid]
|
|
408
|
+
|
|
409
|
+
scores: dict[str, Score] = {"disambiguate": Score(), "prior": Score()}
|
|
410
|
+
for t in THRESHOLDS:
|
|
411
|
+
scores[f"jev conf>={t}"] = Score()
|
|
412
|
+
cand_recall, usage = Counter(), Counter()
|
|
413
|
+
|
|
414
|
+
for n, doc in enumerate(docs, 1):
|
|
415
|
+
print(f"# {n}/{len(docs)} {doc.corpus}/{doc.name} ({len(doc.mentions)} mentions)", file=sys.stderr)
|
|
416
|
+
ef_links = disambiguate(doc, args.ef, args.min_selector)
|
|
417
|
+
cands = candidates_of(doc, args.k, args.ef)
|
|
418
|
+
answers, u = ({}, {}) if args.no_jev else jev(doc, cands, args.model, key, args.refresh_jev)
|
|
419
|
+
usage.update(u)
|
|
420
|
+
for m in doc.mentions:
|
|
421
|
+
scores["disambiguate"].add(m, ef_links.get((m.start, m.end)))
|
|
422
|
+
cs = cands.get(m.surface, [])
|
|
423
|
+
scores["prior"].add(m, max(cs, key=lambda c: c["prior"])["qid"] if cs else None)
|
|
424
|
+
if m.qid:
|
|
425
|
+
cand_recall["gold"] += 1
|
|
426
|
+
cand_recall["in_candidates"] += any(c["qid"] == m.qid for c in cs)
|
|
427
|
+
a = answers.get(m.surface)
|
|
428
|
+
for t in THRESHOLDS:
|
|
429
|
+
pred = None
|
|
430
|
+
if a and a["choice"] != NONE and a["confidence"] >= t:
|
|
431
|
+
pred = a["choice"]
|
|
432
|
+
scores[f"jev conf>={t}"].add(m, pred)
|
|
433
|
+
if a and m.qid: # keep_ambiguity: the gold QID among the 2 most probable non-NONE options
|
|
434
|
+
best2 = [q for q, _ in sorted(a["probabilities"].items(), key=lambda kv: -kv[1]) if q != NONE][:2]
|
|
435
|
+
cand_recall["top2"] += m.qid in best2
|
|
436
|
+
|
|
437
|
+
mentions = sum(len(d.mentions) for d in docs)
|
|
438
|
+
print(f"\n{len(docs)} documents, {mentions} mentions scored ({unresolved} gold titles with no QID dropped)")
|
|
439
|
+
print(f"candidate recall @K={args.k}: {cand_recall['in_candidates']}/{cand_recall['gold']} = {cand_recall['in_candidates'] / max(cand_recall['gold'], 1):.1%} (ceiling of the term-lookup chain)\n")
|
|
440
|
+
for name, s in scores.items():
|
|
441
|
+
if name.startswith("jev") and args.no_jev:
|
|
442
|
+
continue
|
|
443
|
+
print(f"{name:16} {s.row()}")
|
|
444
|
+
if not args.no_jev:
|
|
445
|
+
print(f"{'jev top-2':16} gold QID among the 2 most probable options: {cand_recall['top2']}/{cand_recall['gold']} = {cand_recall['top2'] / max(cand_recall['gold'], 1):.1%}")
|
|
446
|
+
print(f"\nJev usage: {usage['input_tokens']} input tokens, {usage['output_tokens']} output tokens, {usage['input_tokens'] / max(len(docs), 1):.0f} input tokens per document")
|
|
447
|
+
return 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
sys.exit(main())
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: new-plugin
|
|
3
|
-
description: Créer un plugin Sherpa — importeur (pyimporters), et plus tard convertisseur, processeur, annotateur, formateur ou segmenteur (pymultirole) — à partir d'un format ou d'un besoin : clone la graine du contrat, écrit la classe et ses options, les tests et leurs fixtures, la table d'options du README, et câble le plugin dans le paquet de flavor du serveur. À utiliser quand on demande un nouvel importeur, un nouveau format de thésaurus, ou un nouveau plugin pyimporters / pymultirole.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Créer un plugin Sherpa
|
|
7
|
-
|
|
8
|
-
Tu fais **deux choses de nature différente**, et il ne faut pas les confondre :
|
|
9
|
-
|
|
10
|
-
- **l'enveloppe** (`pyproject.toml`, `Taskfile.yml`, `Jenkinsfile`, `.gitignore`…)
|
|
11
|
-
est produite par la graine et sa task `init`. **Tu ne l'écris pas, tu la lances.**
|
|
12
|
-
Elle doit rester identique aux dépôts frères à l'identifiant près ; la réécrire
|
|
13
|
-
de mémoire, c'est fabriquer un neuvième dépôt subtilement différent.
|
|
14
|
-
- **le contenu** (le parser, ses options, les tests, les fixtures, le README, le
|
|
15
|
-
câblage inter-dépôts) demande du jugement. C'est ton travail.
|
|
16
|
-
|
|
17
|
-
La méthode, ce qu'elle remplace et pourquoi : `md/PLUGIN_TEMPLATE.md` du poste,
|
|
18
|
-
ou le `README.md` de la graine.
|
|
19
|
-
|
|
20
|
-
## 1. Rassembler l'essentiel (demander, ne pas supposer)
|
|
21
|
-
|
|
22
|
-
- **le type** — `importer` aujourd'hui ; voir `references/types.md` pour la table
|
|
23
|
-
type → graine, groupe d'entry-points, classe de base, paquet de flavor. Si le
|
|
24
|
-
type demandé n'a pas encore de graine, **le dire et s'arrêter** : ne pas
|
|
25
|
-
improviser une enveloppe.
|
|
26
|
-
- **le nom** — en minuscules, sans préfixe : `foo` donne `pyimporters_foo`,
|
|
27
|
-
`pyimporters-foo`, et l'entry-point `foo`.
|
|
28
|
-
- **le préfixe de classe** — la casse est libre et suit le format, pas le nom :
|
|
29
|
-
`CSV`, `MeSH`, `SKOSRF`. Il donne `<CLASS>KnowledgeParser` et `<CLASS>Options`.
|
|
30
|
-
- **la description** — une ligne, sur le patron des dépôts frères :
|
|
31
|
-
« Sherpa FOO knowledge import plugin ».
|
|
32
|
-
- **le format source** — un fichier d'exemple si possible, sinon la spec. Sans
|
|
33
|
-
échantillon, les options et le compte attendu des tests sont des devinettes :
|
|
34
|
-
demander.
|
|
35
|
-
|
|
36
|
-
## 2. L'enveloppe : lancer la graine, ne rien inventer
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
git clone git@bitbucket.org:kairntech/pyimporters_seed.git pyimporters_<nom>
|
|
40
|
-
cd pyimporters_<nom>
|
|
41
|
-
# Le submodule d'ABORD : le Taskfile l'INCLUT, donc Task refuse de charger le
|
|
42
|
-
# fichier — et 'task init' n'existe pas — tant qu'il n'est pas initialisé.
|
|
43
|
-
git submodule update --init
|
|
44
|
-
task init NAME=<nom> CLASS=<CLASS> DESC="<description>"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
`init` renomme le paquet, le module, le test et sa fixture, substitue
|
|
48
|
-
l'identifiant, écrit la description à ses trois emplacements, remet la version à
|
|
49
|
-
`1.6.0`, retire le `publish: false` du Jenkinsfile, coupe la section « graine »
|
|
50
|
-
du README, se supprime du Taskfile, et repointe `origin` en gardant `seed` en
|
|
51
|
-
lecture seule.
|
|
52
|
-
|
|
53
|
-
Vérifier tout de suite, avant d'écrire une ligne de code :
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
task # doit sortir en 0 sur le squelette
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## 3. Le parser
|
|
60
|
-
|
|
61
|
-
Remplacer le corps de `pyimporters_<nom>/<nom>.py`, **garder la forme** :
|
|
62
|
-
|
|
63
|
-
- les options dérivent de `KnowledgeParserOptions` et se déclarent en
|
|
64
|
-
`Query(défaut, description=...)` — le schéma remonte dans l'API du serveur,
|
|
65
|
-
donc chaque `description` est de la documentation utilisateur, pas un commentaire ;
|
|
66
|
-
- `parse()` est un **générateur** qui `yield` des `Term` ; ne pas construire de liste ;
|
|
67
|
-
- `maybe_archive(source, encoding=...)` déplie `zip`/`gzip` de façon
|
|
68
|
-
transparente : s'en servir plutôt que d'ouvrir le fichier, et ajouter `"zip"`
|
|
69
|
-
aux extensions ;
|
|
70
|
-
- renseigner `bar.max` puis `bar.start()` quand le nombre de termes est
|
|
71
|
-
connaissable d'avance (une première passe de comptage est acceptable), sinon se
|
|
72
|
-
contenter de `bar.next()` ;
|
|
73
|
-
- `get_extensions()` liste les extensions **sans le point** : `["csv", "tsv", "zip"]`.
|
|
74
|
-
|
|
75
|
-
Un `Term` a `identifier` (obligatoire), `preferredForm`, `lexicon`, `properties`
|
|
76
|
-
(dict libre — c'est là que vont les formes alternatives, sous `altForms`).
|
|
77
|
-
|
|
78
|
-
## 4. Les tests et leurs fixtures
|
|
79
|
-
|
|
80
|
-
Deux tests au minimum, dans la forme des dépôts frères :
|
|
81
|
-
|
|
82
|
-
- un test de parsing avec un **vrai fichier** dans `tests/data/`, et un
|
|
83
|
-
`check_expected()` qui vérifie un compte de concepts et un `Term` précis à un
|
|
84
|
-
index donné. Un compte exact vaut mieux qu'un `assert len(...) > 0` : c'est ce
|
|
85
|
-
qui attrape une régression de séparateur ou d'encodage.
|
|
86
|
-
- **garder le test de découverte par entry-point** que la graine fournit, en
|
|
87
|
-
ajustant le nom. Un plugin dont l'entry-point est faux s'importe très bien,
|
|
88
|
-
passe tous ses tests de parsing, et n'est jamais appelé par le serveur.
|
|
89
|
-
|
|
90
|
-
Si le format se distribue en archive, ajouter un test sur le `.zip` — comme
|
|
91
|
-
`test_zipped_text` dans `pyimporters_csv`.
|
|
92
|
-
|
|
93
|
-
## 5. Le README
|
|
94
|
-
|
|
95
|
-
Compléter la table des options laissée par la graine : une ligne par option,
|
|
96
|
-
avec son défaut et sa description, **plus le comportement de repli** quand il
|
|
97
|
-
n'est pas évident (dans `pyimporters_csv`, un nom de colonne inconnu retombe sur
|
|
98
|
-
la colonne 0 et journalise un avertissement — ça se documente).
|
|
99
|
-
|
|
100
|
-
## 6. Le câblage inter-dépôts — sans lui, le plugin n'existe pas
|
|
101
|
-
|
|
102
|
-
Le serveur découvre les parsers par entry-point, mais **seulement parmi les
|
|
103
|
-
paquets installés**, et ce qui est installé est la liste de dépendances d'un
|
|
104
|
-
paquet de flavor. Un plugin non câblé ne remonte jamais dans l'API.
|
|
105
|
-
|
|
106
|
-
- ajouter `"pyimporters-<nom>>=1.6.0,<1.7.0"` aux `dependencies` du paquet de
|
|
107
|
-
flavor (`references/types.md` dit lequel ; pour un importeur,
|
|
108
|
-
`pyimporters_server/packages/pyimporters-builtins/pyproject.toml`) ;
|
|
109
|
-
- aligner la borne de `pyimporters_plugins` sur celle des dépôts frères, ne pas
|
|
110
|
-
l'élargir ;
|
|
111
|
-
- créer le dépôt Bitbucket `kairntech/pyimporters_<nom>` (**underscore** : seul le
|
|
112
|
-
nom de distribution prend le tiret), pousser, vérifier le premier build.
|
|
113
|
-
|
|
114
|
-
## 7. Vérifier, et ne rendre la main que vert
|
|
115
|
-
|
|
116
|
-
```bash
|
|
117
|
-
task # lint, tests, SBOM, CVE, build
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
Corriger jusqu'au 0 — ruff compris. Ne pas déclarer terminé sur un `task` rouge,
|
|
121
|
-
et ne pas désactiver une règle pour faire passer le lint.
|
|
122
|
-
|
|
123
|
-
## Ce qu'il ne faut pas faire
|
|
124
|
-
|
|
125
|
-
- réécrire l'enveloppe à la main, ou « améliorer » le `pyproject.toml` : l'écart
|
|
126
|
-
avec les dépôts frères est le défaut, pas la variété ;
|
|
127
|
-
- toucher à `submodules/` : code épinglé, hors de notre autorité ;
|
|
128
|
-
- réciter l'ordre du pipeline de mémoire. Il vit dans `STAGES` et l'archétype
|
|
129
|
-
peut avoir bougé depuis l'installation de ce plugin : l'interroger
|
|
130
|
-
(`task stages`, `task --list`) ;
|
|
131
|
-
- versionner `uv.lock` (`LOCK_VERSIONED: "false"` ici : `py:sync` résout à neuf) ;
|
|
132
|
-
- écrire la version à la main : c'est la CI qui la pose.
|
pyannotators_entityfishing-1.6.71/submodules/python-archetype/skills/new-plugin/references/types.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Table des types de plugins
|
|
2
|
-
|
|
3
|
-
Ce qui distingue un type d'un autre est **de la donnée** — quatre colonnes. C'est
|
|
4
|
-
la raison pour laquelle il y a *un* skill `new-plugin` et non un par famille :
|
|
5
|
-
six skills à 90 % identiques dériveraient les uns des autres, et rien ne lint un
|
|
6
|
-
skill.
|
|
7
|
-
|
|
8
|
-
| type | graine | groupe d'entry-points | classe de base | paquet de flavor | graine prête ? |
|
|
9
|
-
|------|--------|-----------------------|----------------|------------------|----------------|
|
|
10
|
-
| `importer` | `kairntech/pyimporters_seed` | `pyimporters.plugins` | `KnowledgeParserBase` (`pyimporters_plugins.base`) | `pyimporters_server/packages/pyimporters-builtins` | **oui** |
|
|
11
|
-
| `converter` | `kairntech/pyconverters_seed` | `pyconverters.plugins` | `ConverterBase` (`pymultirole_plugins.v1.converter`) | `pymultirole_server/packages/pymultirole-builtins` | non |
|
|
12
|
-
| `processor` | `kairntech/pyprocessors_seed` | `pyprocessors.plugins` | `ProcessorBase` (`pymultirole_plugins.v1.processor`) | `pymultirole-builtins` ou une flavor dédiée | non |
|
|
13
|
-
| `annotator` | `kairntech/pyannotators_seed` | `pyannotators.plugins` | `AnnotatorBase` (`pymultirole_plugins.v1.annotator`) | `pymultirole-builtins` | non |
|
|
14
|
-
| `formatter` | `kairntech/pyformatters_seed` | `pyformatters.plugins` | `FormatterBase` (`pymultirole_plugins.v1.formatter`) | `pymultirole-builtins` | non |
|
|
15
|
-
| `segmenter` | `kairntech/pysegmenters_seed` | `pysegmenters.plugins` | `SegmenterBase` (`pymultirole_plugins.v1.segmenter`) | `pymultirole-builtins` | non |
|
|
16
|
-
|
|
17
|
-
## Une seule graine existe aujourd'hui
|
|
18
|
-
|
|
19
|
-
`pyimporters_seed` est poussée, son build est vert, et elle ne publie rien
|
|
20
|
-
(`publish: false`). **Les cinq autres n'existent pas** : la famille
|
|
21
|
-
`pymultirole` n'est pas encore passée sous `python-archetype`, et ses 63 dépôts
|
|
22
|
-
portent encore la sortie de `cookiecutter-pymultirole` (`bumpversion.py`,
|
|
23
|
-
`AUTHORS.md`, `CHANGELOG.md`, `RELEASE.md`, `mypy.ini`, `docs/`, layout `src/`).
|
|
24
|
-
|
|
25
|
-
Si on demande un type dont la graine n'existe pas : **le dire et s'arrêter.**
|
|
26
|
-
Improviser l'enveloppe reviendrait à recréer à la main ce que la graine garantit,
|
|
27
|
-
sans la garantie — et le dépôt produit devrait être migré comme les 63 autres.
|
|
28
|
-
|
|
29
|
-
## Pourquoi une graine par contrat, et un seul skill
|
|
30
|
-
|
|
31
|
-
Une graine doit **builder** : un `ConverterBase` et un `ProcessorBase` n'ont pas
|
|
32
|
-
la même signature, donc un dépôt ne peut pas builder les deux. Une graine
|
|
33
|
-
paramétrée qui supprimerait quatre contrats sur cinq à l'`init` redeviendrait un
|
|
34
|
-
template — du code non exécutable, hors CI, exactement le défaut qu'on corrige.
|
|
35
|
-
|
|
36
|
-
Un skill, lui, n'a rien à builder : le dupliquer ne coûte pas un job Jenkins, ça
|
|
37
|
-
coûte une divergence que personne ne mesure.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyannotators_entityfishing-1.6.71 → pyannotators_entityfishing-1.6.77}/tools/eval_mappings.py
RENAMED
|
File without changes
|
|
File without changes
|