pyannotators-entityfishing 1.6.73__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.
Files changed (30) hide show
  1. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/PKG-INFO +1 -1
  2. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/__init__.py +1 -1
  3. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/.claude-plugin/plugin.json +1 -1
  4. pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/SKILL.md +112 -0
  5. pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/annotator.md +51 -0
  6. pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/importer.md +50 -0
  7. pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/processor.md +55 -0
  8. pyannotators_entityfishing-1.6.77/submodules/python-archetype/skills/new-plugin/references/types.md +61 -0
  9. pyannotators_entityfishing-1.6.77/tools/eval_linking.py +451 -0
  10. pyannotators_entityfishing-1.6.73/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -132
  11. pyannotators_entityfishing-1.6.73/submodules/python-archetype/skills/new-plugin/references/types.md +0 -37
  12. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/.claude/skills/ef-mapping/SKILL.md +0 -0
  13. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/.gitignore +0 -0
  14. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/.gitmodules +0 -0
  15. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/.python-version +0 -0
  16. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/README.md +0 -0
  17. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/Taskfile.yml +0 -0
  18. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/pyproject.toml +0 -0
  19. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/ef_client.py +0 -0
  20. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/src/pyannotators_entityfishing/entityfishing.py +0 -0
  21. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
  22. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/HOWTO.md +0 -0
  23. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/README.md +0 -0
  24. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/resources/Stages.yml +0 -0
  25. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/resources/Taskfile.yml +0 -0
  26. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/tools/check-marketplace.py +0 -0
  27. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
  28. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/tools/el_jev.py +0 -0
  29. {pyannotators_entityfishing-1.6.73 → pyannotators_entityfishing-1.6.77}/tools/eval_mappings.py +0 -0
  30. {pyannotators_entityfishing-1.6.73 → 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.73
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>
@@ -1,3 +1,3 @@
1
1
  """Annotator based on entity-fishing"""
2
2
 
3
- __version__ = "1.6.73"
3
+ __version__ = "1.6.77"
@@ -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.0.0",
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`).
@@ -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.
@@ -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.