pyprocessors-coreference 1.6.1__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 (22) hide show
  1. pyprocessors_coreference-1.6.1/.claude/settings.json +13 -0
  2. pyprocessors_coreference-1.6.1/.gitignore +31 -0
  3. pyprocessors_coreference-1.6.1/.gitmodules +4 -0
  4. pyprocessors_coreference-1.6.1/.python-version +1 -0
  5. pyprocessors_coreference-1.6.1/PKG-INFO +131 -0
  6. pyprocessors_coreference-1.6.1/README.md +102 -0
  7. pyprocessors_coreference-1.6.1/docs/adr/0001-linking-candidats-entityfishing-decision-jev.md +78 -0
  8. pyprocessors_coreference-1.6.1/pyproject.toml +74 -0
  9. pyprocessors_coreference-1.6.1/src/pyprocessors_coreference/__init__.py +3 -0
  10. pyprocessors_coreference-1.6.1/src/pyprocessors_coreference/coreference.py +174 -0
  11. pyprocessors_coreference-1.6.1/submodules/python-archetype/.claude-plugin/marketplace.json +18 -0
  12. pyprocessors_coreference-1.6.1/submodules/python-archetype/.claude-plugin/plugin.json +15 -0
  13. pyprocessors_coreference-1.6.1/submodules/python-archetype/HOWTO.md +204 -0
  14. pyprocessors_coreference-1.6.1/submodules/python-archetype/README.md +120 -0
  15. pyprocessors_coreference-1.6.1/submodules/python-archetype/resources/Stages.yml +114 -0
  16. pyprocessors_coreference-1.6.1/submodules/python-archetype/skills/new-plugin/SKILL.md +112 -0
  17. pyprocessors_coreference-1.6.1/submodules/python-archetype/skills/new-plugin/references/annotator.md +51 -0
  18. pyprocessors_coreference-1.6.1/submodules/python-archetype/skills/new-plugin/references/importer.md +50 -0
  19. pyprocessors_coreference-1.6.1/submodules/python-archetype/skills/new-plugin/references/processor.md +55 -0
  20. pyprocessors_coreference-1.6.1/submodules/python-archetype/skills/new-plugin/references/types.md +61 -0
  21. pyprocessors_coreference-1.6.1/submodules/python-archetype/tools/check-marketplace.py +237 -0
  22. pyprocessors_coreference-1.6.1/submodules/python-archetype/vars/pythonPipeline.groovy +638 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "extraKnownMarketplaces": {
3
+ "kairntech-python": {
4
+ "source": {
5
+ "source": "git",
6
+ "url": "git@bitbucket.org:kairntech/python-archetype.git"
7
+ }
8
+ }
9
+ },
10
+ "enabledPlugins": {
11
+ "python-archetype@kairntech-python": true
12
+ }
13
+ }
@@ -0,0 +1,31 @@
1
+ /.idea/
2
+ /.vscode/
3
+ /dist/
4
+ dist
5
+ .git
6
+ .cache
7
+ **/.pytest_cache
8
+ models
9
+ log
10
+ **/__pycache__/
11
+ *.pyc
12
+ **/test-reports
13
+ **/.passwd-pypi
14
+ **/.env
15
+ **/results.xml
16
+
17
+ # Specific
18
+ .idea/
19
+ .groovylintrc.json
20
+ .emailNotif
21
+ uv.lock
22
+
23
+ # SBOMs
24
+ **/sbom*.json
25
+ **/trivy*.html
26
+ **/audit*.json
27
+ # Le rapport lisible que py:vuln-report ecrit (AUDIT_REPORT). Ajoute apres les
28
+ # regles ci-dessus, il n'etait couvert par aucune : les huit depots ont donc un
29
+ # audit-report.md non suivi qui traine apres chaque 'task'. Meme correctif a y
30
+ # porter -- c'est du ressort de check-plugin-drift.
31
+ **/audit*.md
@@ -0,0 +1,4 @@
1
+ [submodule "submodules/python-archetype"]
2
+ path = submodules/python-archetype
3
+ url = git@bitbucket.org:kairntech/python-archetype.git
4
+ branch = master
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyprocessors-coreference
3
+ Version: 1.6.1
4
+ Summary: Processor grouping the mentions of the same entity
5
+ Project-URL: Homepage, https://bitbucket.org/kairntech/pyprocessors_coreference
6
+ Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
7
+ License: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Information Technology
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: pymultirole-plugins<1.7.0,>=1.6.0
21
+ Provides-Extra: sbom
22
+ Requires-Dist: cyclonedx-bom; extra == 'sbom'
23
+ Requires-Dist: pip-audit; extra == 'sbom'
24
+ Provides-Extra: test
25
+ Requires-Dist: pip; extra == 'test'
26
+ Requires-Dist: pytest; extra == 'test'
27
+ Requires-Dist: ruff; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # pyprocessors-coreference
31
+
32
+ Processor grouping the mentions of the same entity.
33
+
34
+ Ce dépôt enregistre un processeur dans le groupe d'entry-points `pyprocessors.plugins` :
35
+
36
+ | Plugin | Classe |
37
+ |--------|--------|
38
+ | `coreference` | `pyprocessors_coreference.coreference:CoreferenceProcessor` |
39
+
40
+ Il regroupe les mentions d'une même entité **avant** le linking, pour qu'elles soient
41
+ liées une seule fois, à partir de leur forme la plus longue. C'est l'étape de
42
+ regroupement des mentions de l'ADR-0001 de `pyannotators_entityfishing`
43
+ ([`docs/adr/`](docs/adr/0001-linking-candidats-entityfishing-decision-jev.md)), palier 1 :
44
+ les noms seulement.
45
+
46
+ ## Ce qu'il fait
47
+
48
+ Pour chaque document, entre mentions de même label :
49
+
50
+ - **même surface normalisée → même groupe.** La normalisation ignore la casse, les
51
+ espaces, le style des apostrophes, et les civilités en tête d'un nom de personne
52
+ (« M. », « Mme », « Dr »…) ;
53
+ - **tous les labels de personne forment une seule famille** : le `person` du NER et
54
+ l'`afpperson` des lexiques AFP annotent le même span, et vont dans le même groupe ;
55
+ - **un nom partiel de personne rejoint le nom complet dont il est la fin**, que le
56
+ nom complet soit avant ou après lui dans le document : « Macron » et « M. Macron »
57
+ rejoignent « Emmanuel Macron » ;
58
+ - **un prénom seul ne rejoint jamais un nom complet** : « Emmanuel » reste à part ;
59
+ - **un nom partiel ambigu n'est pas fusionné** : avec « Emmanuel Macron » et
60
+ « Brigitte Macron » dans le texte, « Macron » garde son propre groupe, et liste les
61
+ groupes possibles pour que l'étape de linking tranche ;
62
+ - **la règle du nom partiel ne vaut que pour les personnes** : « Airbus » ne rejoint pas
63
+ « Airbus Defence and Space ».
64
+
65
+ Sur chaque annotation d'un label regroupé, il écrit dans `properties` :
66
+
67
+ | Propriété | Valeur |
68
+ |-----------|--------|
69
+ | `entity_group` | l'identifiant du groupe dans le document : `g1`, `g2`… dans l'ordre de première apparition |
70
+ | `canonical_form` | la forme la plus longue du groupe, sans civilité : c'est elle qu'on cherche dans la base de connaissances |
71
+ | `mention_type` | `name` (les périphrases et les pronoms, `nominal` et `pronoun`, viendront avec les paliers 2 et 3) |
72
+ | `entity_group_candidates` | seulement sur un nom partiel ambigu : les groupes auxquels il peut appartenir |
73
+
74
+ Les autres propriétés de l'annotation sont gardées, et traiter deux fois un document
75
+ donne le même résultat. La portée est le document entier, ce qui convient à une
76
+ dépêche ; les documents longs sont une question ouverte (SHERPA-3048).
77
+
78
+ ## Requirements
79
+
80
+ - Python 3.12+
81
+ - `pymultirole_plugins` (>=1.6.0,<1.7.0)
82
+
83
+ ## Installation
84
+
85
+ ```
86
+ pip install pyprocessors-coreference
87
+ ```
88
+
89
+ ## Usage
90
+
91
+ ```python
92
+ from pymultirole_plugins.v1.schema import Annotation, Document
93
+
94
+ from pyprocessors_coreference.coreference import CoreferenceParameters, CoreferenceProcessor
95
+
96
+ text = "Emmanuel Macron est arrivé. M. Macron a parlé."
97
+ document = Document(
98
+ text=text,
99
+ annotations=[
100
+ Annotation(start=0, end=15, labelName="person", text="Emmanuel Macron"),
101
+ Annotation(start=28, end=37, labelName="person", text="M. Macron"),
102
+ ],
103
+ )
104
+ [document] = CoreferenceProcessor().process([document], CoreferenceParameters())
105
+ # Both annotations: entity_group "g1", canonical_form "Emmanuel Macron".
106
+ ```
107
+
108
+ ### Parameters
109
+
110
+ | Paramètre | Défaut | Description |
111
+ |-----------|--------|-------------|
112
+ | `labels` | vide | Labels des mentions à regrouper. Vide : tous les labels. |
113
+ | `person_labels` | vide | Labels de personne : ils forment une seule famille, et la règle du nom partiel s'y applique. Vide : tout label dont le nom contient « person » (`person`, `afpperson`…). |
114
+ | `honorifics` | `M.`, `Mme`, `Dr`, `Mr`… | Civilités ignorées en tête d'un nom de personne. Paramètre avancé. |
115
+
116
+ ## Development
117
+
118
+ Le build est piloté par [Task](https://taskfile.dev) et [uv](https://docs.astral.sh/uv/),
119
+ les stages venant du submodule `python-archetype` :
120
+
121
+ ```
122
+ git submodule update --init
123
+ task stages # les stages du pipeline, dans l'ordre
124
+ task # tout sauf le dernier stage — donc tout sauf la publication
125
+ task -- --skip-tests # idem, sans le stage de test
126
+ task up-to -- py:lint
127
+ task jenkins # tout, exactement ce que joue Jenkins
128
+ task --list
129
+ ```
130
+
131
+ `uv.lock` n'est pas versionné ici, donc `py:sync` résout les dépendances à neuf.
@@ -0,0 +1,102 @@
1
+ # pyprocessors-coreference
2
+
3
+ Processor grouping the mentions of the same entity.
4
+
5
+ Ce dépôt enregistre un processeur dans le groupe d'entry-points `pyprocessors.plugins` :
6
+
7
+ | Plugin | Classe |
8
+ |--------|--------|
9
+ | `coreference` | `pyprocessors_coreference.coreference:CoreferenceProcessor` |
10
+
11
+ Il regroupe les mentions d'une même entité **avant** le linking, pour qu'elles soient
12
+ liées une seule fois, à partir de leur forme la plus longue. C'est l'étape de
13
+ regroupement des mentions de l'ADR-0001 de `pyannotators_entityfishing`
14
+ ([`docs/adr/`](docs/adr/0001-linking-candidats-entityfishing-decision-jev.md)), palier 1 :
15
+ les noms seulement.
16
+
17
+ ## Ce qu'il fait
18
+
19
+ Pour chaque document, entre mentions de même label :
20
+
21
+ - **même surface normalisée → même groupe.** La normalisation ignore la casse, les
22
+ espaces, le style des apostrophes, et les civilités en tête d'un nom de personne
23
+ (« M. », « Mme », « Dr »…) ;
24
+ - **tous les labels de personne forment une seule famille** : le `person` du NER et
25
+ l'`afpperson` des lexiques AFP annotent le même span, et vont dans le même groupe ;
26
+ - **un nom partiel de personne rejoint le nom complet dont il est la fin**, que le
27
+ nom complet soit avant ou après lui dans le document : « Macron » et « M. Macron »
28
+ rejoignent « Emmanuel Macron » ;
29
+ - **un prénom seul ne rejoint jamais un nom complet** : « Emmanuel » reste à part ;
30
+ - **un nom partiel ambigu n'est pas fusionné** : avec « Emmanuel Macron » et
31
+ « Brigitte Macron » dans le texte, « Macron » garde son propre groupe, et liste les
32
+ groupes possibles pour que l'étape de linking tranche ;
33
+ - **la règle du nom partiel ne vaut que pour les personnes** : « Airbus » ne rejoint pas
34
+ « Airbus Defence and Space ».
35
+
36
+ Sur chaque annotation d'un label regroupé, il écrit dans `properties` :
37
+
38
+ | Propriété | Valeur |
39
+ |-----------|--------|
40
+ | `entity_group` | l'identifiant du groupe dans le document : `g1`, `g2`… dans l'ordre de première apparition |
41
+ | `canonical_form` | la forme la plus longue du groupe, sans civilité : c'est elle qu'on cherche dans la base de connaissances |
42
+ | `mention_type` | `name` (les périphrases et les pronoms, `nominal` et `pronoun`, viendront avec les paliers 2 et 3) |
43
+ | `entity_group_candidates` | seulement sur un nom partiel ambigu : les groupes auxquels il peut appartenir |
44
+
45
+ Les autres propriétés de l'annotation sont gardées, et traiter deux fois un document
46
+ donne le même résultat. La portée est le document entier, ce qui convient à une
47
+ dépêche ; les documents longs sont une question ouverte (SHERPA-3048).
48
+
49
+ ## Requirements
50
+
51
+ - Python 3.12+
52
+ - `pymultirole_plugins` (>=1.6.0,<1.7.0)
53
+
54
+ ## Installation
55
+
56
+ ```
57
+ pip install pyprocessors-coreference
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ from pymultirole_plugins.v1.schema import Annotation, Document
64
+
65
+ from pyprocessors_coreference.coreference import CoreferenceParameters, CoreferenceProcessor
66
+
67
+ text = "Emmanuel Macron est arrivé. M. Macron a parlé."
68
+ document = Document(
69
+ text=text,
70
+ annotations=[
71
+ Annotation(start=0, end=15, labelName="person", text="Emmanuel Macron"),
72
+ Annotation(start=28, end=37, labelName="person", text="M. Macron"),
73
+ ],
74
+ )
75
+ [document] = CoreferenceProcessor().process([document], CoreferenceParameters())
76
+ # Both annotations: entity_group "g1", canonical_form "Emmanuel Macron".
77
+ ```
78
+
79
+ ### Parameters
80
+
81
+ | Paramètre | Défaut | Description |
82
+ |-----------|--------|-------------|
83
+ | `labels` | vide | Labels des mentions à regrouper. Vide : tous les labels. |
84
+ | `person_labels` | vide | Labels de personne : ils forment une seule famille, et la règle du nom partiel s'y applique. Vide : tout label dont le nom contient « person » (`person`, `afpperson`…). |
85
+ | `honorifics` | `M.`, `Mme`, `Dr`, `Mr`… | Civilités ignorées en tête d'un nom de personne. Paramètre avancé. |
86
+
87
+ ## Development
88
+
89
+ Le build est piloté par [Task](https://taskfile.dev) et [uv](https://docs.astral.sh/uv/),
90
+ les stages venant du submodule `python-archetype` :
91
+
92
+ ```
93
+ git submodule update --init
94
+ task stages # les stages du pipeline, dans l'ordre
95
+ task # tout sauf le dernier stage — donc tout sauf la publication
96
+ task -- --skip-tests # idem, sans le stage de test
97
+ task up-to -- py:lint
98
+ task jenkins # tout, exactement ce que joue Jenkins
99
+ task --list
100
+ ```
101
+
102
+ `uv.lock` n'est pas versionné ici, donc `py:sync` résout les dépendances à neuf.
@@ -0,0 +1,78 @@
1
+ ---
2
+ status: proposed
3
+ date: 2026-09-24
4
+ ---
5
+
6
+ # ADR (relais) — Le linking d'entités sépare les candidats (entity-fishing) de la décision (Jev)
7
+
8
+ - **Décision** — C'est une ADR relais : la décision est prise, et argumentée, dans l'[ADR-0001 de `pyannotators_entityfishing`](https://bitbucket.org/kairntech/pyannotators_entityfishing/src/master/docs/adr/0001-linking-candidats-entityfishing-decision-jev.md). On ne la recopie pas ici, pour que les deux textes ne divergent pas.
9
+ - **Pourquoi ce dépôt existe** — Il porte l'étape de **regroupement des mentions** du pipeline de linking. Elle se place entre le NER et la génération des candidats : les mentions d'une même entité sont regroupées **avant** le linking, pour être liées une seule fois, à partir de leur forme la plus longue.
10
+ - **Statut** — `proposed`. C'est le statut de l'ADR source qui fait foi.
11
+
12
+ ---
13
+
14
+ - **Source** : [ADR-0001, `pyannotators_entityfishing`](https://bitbucket.org/kairntech/pyannotators_entityfishing/src/master/docs/adr/0001-linking-candidats-entityfishing-decision-jev.md), en particulier §3 (le pipeline et le regroupement), §4 (ce qui change par dépôt) et §5 (le contrat entre les étapes).
15
+ - **Suivi** (essai) : Epic [SHERPA-3035](https://kairntech.atlassian.net/browse/SHERPA-3035). Tickets de ce dépôt :
16
+ - [SHERPA-3038](https://kairntech.atlassian.net/browse/SHERPA-3038) : le processeur, palier 1 (les noms) ;
17
+ - [SHERPA-3046](https://kairntech.atlassian.net/browse/SHERPA-3046) : le nom et la graine du dépôt, tranchés par sa création même ;
18
+ - [SHERPA-3047](https://kairntech.atlassian.net/browse/SHERPA-3047) : les paliers 2 et 3 (périphrases, pronoms) ;
19
+ - [SHERPA-3048](https://kairntech.atlassian.net/browse/SHERPA-3048) : la portée du regroupement pour les documents longs.
20
+
21
+ ## Le nom et l'enveloppe
22
+
23
+ - **Le nom : `pyprocessors_coreference`, plutôt que `pyprocessors_name_propagator`.** Un « propagateur de noms » décrit ce que fait l'AFP aujourd'hui : recopier des liens **après** le linking. Ici, on regroupe **avant**, et les paliers 2 et 3 relèvent de la coréférence.
24
+ - **L'enveloppe vient de `pyprocessors_seed`** (`task init`), la graine des processeurs créée pour l'occasion. La mesure de dérive se fait par `git fetch seed && git diff seed/master -- . ':!src' ':!tests'`.
25
+
26
+ ## Ce que ce dépôt doit faire
27
+
28
+ **Palier 1 (SHERPA-3038).** Le processeur ajoute à chaque annotation du NER :
29
+ - `properties.entity_group` : l'identifiant du groupe dans le document ;
30
+ - `properties.canonical_form` : la forme la plus longue du groupe, qui sert au term lookup.
31
+
32
+ Les règles s'appliquent toujours entre mentions de même label NER :
33
+ - **même surface normalisée → même groupe** ;
34
+ - **PERSON seulement : un nom partiel rejoint un nom complet vu dans le document** (« Macron » rejoint « Emmanuel Macron ») ;
35
+ - **un nom partiel ambigu n'est pas fusionné de force** : avec Emmanuel et Brigitte Macron dans le même texte, « Macron » garde ses deux groupes possibles.
36
+
37
+ **Le code part de `person_varnames`**, aujourd'hui en double dans `pyprocessors_afp_entities` et `pyprocessors_escrim_reconciliation`, qui a trois défauts :
38
+ - la portée ne va que vers l'avant ;
39
+ - un prénom seul est enregistré comme variante ;
40
+ - les civilités (« M. », « Mme ») ne sont pas retirées.
41
+
42
+ Il était prévu de l'extraire. **Le palier 1 a finalement réécrit la règle**, parce que deux des trois défauts sont dans sa logique même : les variantes par préfixe, qui produisent le prénom seul, et le parcours vers l'avant seulement. Seul le rattachement d'une fin de nom au nom complet est conservé.
43
+
44
+ ## Ce que le palier 1 fait, et ses limites connues
45
+
46
+ **Une décision prise en l'écrivant : les labels de personne forment une seule famille.** Dans le pipeline AFP, le NER (`person`) et le lexique AFP (`afpperson`) annotent le **même span**. Grouper strictement par label mettait chaque personne dans deux groupes. Le paramètre `person_labels` désigne ces labels ; s'il est vide, ce sont ceux dont le nom contient « person ». Les autres labels restent groupés par label exact : `location`/`afplocation` et `organization`/`afporganization` ont sans doute le même doublon, et la question reste ouverte.
47
+
48
+ **Ce qu'est une « fin de nom ».** Le nom partiel rejoint le nom complet si ses mots sont **exactement les derniers mots** du nom complet. La comparaison se fait mot à mot, après normalisation (casse, espaces, apostrophes, civilités), jamais sur une partie de mot :
49
+
50
+ | Nom partiel | Nom complet | Résultat |
51
+ |---|---|---|
52
+ | Lucas | Franck DeLucas | reste à part : `delucas` est un seul mot |
53
+ | Luc | Jean-Luc Mélenchon | reste à part : « Jean-Luc » est un seul mot |
54
+ | Smith | Emile Smith Rowe | reste à part : ce n'est pas la fin du nom |
55
+ | Mélenchon, Villepin | Jean-Luc Mélenchon, Dominique de Villepin | rejoint |
56
+ | de Gaulle, Le Pen, Smith Rowe | Charles de Gaulle, Marine Le Pen, Emile Smith Rowe | rejoint |
57
+
58
+ **Limite connue, acceptée : un nom de famille à particule ou composé peut être amputé.** Ces rattachements se font, alors qu'ils ne correspondent pas au nom de famille entier :
59
+
60
+ | Nom partiel | Nom complet |
61
+ |---|---|
62
+ | Gaulle | Charles de Gaulle |
63
+ | Pen | Marine Le Pen |
64
+ | Lucas | Franck De Lucas |
65
+ | Rowe | Emile Smith Rowe |
66
+
67
+ **On n'ajoute pas de règle sur les particules (décision du 2026-09-24).** Une telle règle aurait interdit à une fin de nom de commencer juste après une particule (de, le, van, von, da, del…). Elle n'est pas retenue, pour trois raisons :
68
+ - **un NER isole rarement** « Gaulle » ou « Pen » ;
69
+ - **les noms composés sans particule** (« Smith Rowe ») resteraient de toute façon hors de portée sans dictionnaire ;
70
+ - **l'erreur ne pèse qu'en cas d'homonyme.** Elle ne compte que si un autre porteur du nom amputé figure dans le même document, par exemple un autre Lucas à côté de « Franck De Lucas ». Or un nom partiel qui correspond à plusieurs noms complets n'est de toute façon pas fusionné.
71
+
72
+ C'est à revoir si l'évaluation sur le corpus annoté ([SHERPA-3037](https://kairntech.atlassian.net/browse/SHERPA-3037)) montre de tels faux rattachements.
73
+
74
+ ## Ce qu'il ne faut pas faire en attendant
75
+
76
+ - **Ne pas publier le squelette.** Tant que le palier 1 n'est pas écrit, le code est celui de la graine : il ajoute une catégorie à chaque document. Le premier build de `master` publierait ce squelette sur l'index sous le nom `pyprocessors-coreference`, et l'index est immuable. On travaille donc sur une branche de feature, que `publishBranches: '^master$'` empêche de publier, et `master` ne reçoit que le palier 1 terminé.
77
+ - **Ne pas commencer les paliers 2 et 3** avant l'évaluation de SHERPA-3047.
78
+ - **Ne pas câbler le processeur dans un paquet de flavor de `pymultirole_server`** avant qu'il ait été validé dans le pipeline de bout en bout ([SHERPA-3041](https://kairntech.atlassian.net/browse/SHERPA-3041)).
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyprocessors-coreference"
7
+ dynamic = ["version"]
8
+ description = "Processor grouping the mentions of the same entity"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Olivier Terrier", email = "olivier.terrier@kairntech.com"},
14
+ ]
15
+ classifiers = [
16
+ "Intended Audience :: Information Technology",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Topic :: Software Development",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Development Status :: 4 - Beta",
26
+ "Programming Language :: Python :: 3.12",
27
+ ]
28
+ dependencies = [
29
+ "pymultirole-plugins>=1.6.0,<1.7.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://bitbucket.org/kairntech/pyprocessors_coreference"
34
+
35
+ [project.entry-points."pyprocessors.plugins"]
36
+ coreference = "pyprocessors_coreference.coreference:CoreferenceProcessor"
37
+
38
+ [project.optional-dependencies]
39
+ test = [
40
+ "pytest",
41
+ "ruff",
42
+ "pip",
43
+ ]
44
+ sbom = ["cyclonedx-bom", "pip-audit"]
45
+
46
+ [tool.hatch.version]
47
+ path = "src/pyprocessors_coreference/__init__.py"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/pyprocessors_coreference"]
51
+
52
+ [tool.hatch.build]
53
+ exclude = [
54
+ "/tests",
55
+ "Jenkinsfile",
56
+ "Taskfile.yml",
57
+ "CLAUDE.md",
58
+ ".gitignore"
59
+ ]
60
+
61
+ [tool.pytest.ini_options]
62
+ addopts = "--durations=5"
63
+ norecursedirs = ["docs"]
64
+
65
+ [tool.ruff]
66
+ line-length = 256
67
+ target-version = "py312"
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM"]
71
+
72
+ [tool.ruff.format]
73
+ quote-style = "double"
74
+ indent-style = "space"
@@ -0,0 +1,3 @@
1
+ """Processor grouping the mentions of the same entity"""
2
+
3
+ __version__ = "1.6.1"
@@ -0,0 +1,174 @@
1
+ import re
2
+ import unicodedata
3
+ from collections import defaultdict
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, Field
7
+ from pymultirole_plugins.v1.processor import ProcessorBase, ProcessorParameters
8
+ from pymultirole_plugins.v1.schema import Annotation, Document
9
+
10
+ # Honorifics in front of a person name, stripped before comparing names: in AFP
11
+ # dispatches "M. Macron" follows "Emmanuel Macron". Compared case-insensitively.
12
+ DEFAULT_HONORIFICS = ["M.", "MM.", "Mme", "Mmes", "Mlle", "Mlles", "Me", "Dr", "Dr.", "Pr", "Pr.", "Mr", "Mr.", "Mrs", "Mrs.", "Ms", "Ms.", "Miss", "Sir", "Dame", "Lord", "Lady"]
13
+
14
+ # Properties written on every grouped annotation (ADR-0001, §5).
15
+ ENTITY_GROUP = "entity_group"
16
+ CANONICAL_FORM = "canonical_form"
17
+ MENTION_TYPE = "mention_type"
18
+ # Only on an ambiguous partial name: the groups it may belong to.
19
+ ENTITY_GROUP_CANDIDATES = "entity_group_candidates"
20
+
21
+ # The family of every person label: the NER's "person" and the AFP lexicon's
22
+ # "afpperson" annotate the same span, and must land in the same group.
23
+ PERSON_FAMILY = "\x00person"
24
+
25
+ APOSTROPHES = str.maketrans({"’": "'", "‘": "'", "ʼ": "'"})
26
+
27
+
28
+ class CoreferenceParameters(ProcessorParameters):
29
+ """Parameters of the coreference processor"""
30
+
31
+ labels: list[str] | None = Field(
32
+ None,
33
+ description="""Labels of the mentions to group. If empty, the mentions of every label are grouped.<br/>
34
+ Mentions are only grouped with mentions of the same label, except person labels, which all form one family.""",
35
+ json_schema_extra={"extra": "label"},
36
+ )
37
+ person_labels: list[str] | None = Field(
38
+ None,
39
+ description="""Labels of person names. They form one family (person and afpperson mentions are grouped together),
40
+ and a partial name ("Macron") joins the full name it ends ("Emmanuel Macron").<br/>
41
+ If empty, every label whose name contains "person" (person, afpperson...).""",
42
+ json_schema_extra={"extra": "label"},
43
+ )
44
+ honorifics: list[str] = Field(
45
+ DEFAULT_HONORIFICS,
46
+ description="Honorifics ignored in front of a person name when comparing names (M., Mme, Dr...)",
47
+ json_schema_extra={"extra": "advanced"},
48
+ )
49
+
50
+
51
+ class CoreferenceProcessor(ProcessorBase):
52
+ """Group the mentions of the same entity, before entity linking.
53
+
54
+ Tier 1 of the mention grouping of ADR-0001 (pyannotators_entityfishing): names
55
+ only. Every annotation of a grouped label gets an `entity_group` (the same id for
56
+ every mention of the entity in the document) and a `canonical_form` (the longest
57
+ form of the group, which the candidate generation looks up):
58
+
59
+ - same normalized surface and same label → same group; all person labels count
60
+ as one (the NER's "person" and the AFP lexicon's "afpperson" annotate the same
61
+ span);
62
+ - person labels only: a partial name joins the full name it ends, wherever the
63
+ full name is in the document ("Macron" joins "Emmanuel Macron");
64
+ - a first name alone never joins ("Emmanuel" stays on its own);
65
+ - an ambiguous partial name ("Macron" with both Emmanuel and Brigitte Macron)
66
+ keeps a group of its own, and lists the possible groups in
67
+ `entity_group_candidates` for the linking step to decide.
68
+ """
69
+
70
+ def process(self, documents: list[Document], parameters: ProcessorParameters) -> list[Document]:
71
+ params = cast(CoreferenceParameters, parameters)
72
+ honorifics = {h.casefold() for h in params.honorifics}
73
+ for document in documents:
74
+ group_document(document, params, honorifics)
75
+ return documents
76
+
77
+ @classmethod
78
+ def get_model(cls) -> type[BaseModel]:
79
+ return CoreferenceParameters
80
+
81
+
82
+ def group_document(document: Document, params: CoreferenceParameters, honorifics: set[str]) -> None:
83
+ text = document.text or ""
84
+ mentions = [a for a in (document.annotations or []) if a.labelName and (not params.labels or a.labelName in params.labels)]
85
+ if not mentions:
86
+ return
87
+
88
+ # 1. Same label, same normalized surface: one surface group.
89
+ surfaces: dict[tuple[str, tuple[str, ...]], list[Annotation]] = defaultdict(list)
90
+ for a in sorted(mentions, key=lambda a: (a.start, a.end)):
91
+ person = is_person_label(a.labelName, params)
92
+ tokens = name_tokens(surface_of(a, text), honorifics if person else set())
93
+ if tokens:
94
+ surfaces[(PERSON_FAMILY if person else a.labelName, tokens)].append(a)
95
+
96
+ # 2. Person labels: a surface that is a proper suffix of longer surfaces joins
97
+ # them when they are all one entity. Longest first, so that "Emmanuel Macron" has
98
+ # already joined "Président Emmanuel Macron" when "Macron" is considered.
99
+ parent = {key: key for key in surfaces}
100
+
101
+ def root(key):
102
+ while parent[key] != key:
103
+ parent[key] = parent[parent[key]]
104
+ key = parent[key]
105
+ return key
106
+
107
+ ambiguous: dict[tuple, set] = {}
108
+ for key in sorted(surfaces, key=lambda k: -len(k[1])):
109
+ family, tokens = key
110
+ if family != PERSON_FAMILY:
111
+ continue
112
+ longer = {root(other) for other in surfaces if other[0] == PERSON_FAMILY and is_proper_suffix(tokens, other[1])}
113
+ longer.discard(root(key))
114
+ if len(longer) == 1:
115
+ parent[root(key)] = longer.pop()
116
+ elif longer:
117
+ ambiguous[key] = longer
118
+
119
+ # 3. Ids in order of first appearance, canonical form = longest mention of the group.
120
+ members: dict[tuple, list[Annotation]] = defaultdict(list)
121
+ for key, annotations in surfaces.items():
122
+ members[root(key)].extend(annotations)
123
+ ordered = sorted(members, key=lambda r: min(a.start for a in members[r]))
124
+ group_id = {r: f"g{i}" for i, r in enumerate(ordered, start=1)}
125
+ canonical = {r: canonical_form(members[r], text, honorifics if r[0] == PERSON_FAMILY else set()) for r in ordered}
126
+
127
+ for key, annotations in surfaces.items():
128
+ r = root(key)
129
+ for a in annotations:
130
+ properties = dict(a.properties or {})
131
+ properties[ENTITY_GROUP] = group_id[r]
132
+ properties[CANONICAL_FORM] = canonical[r]
133
+ properties[MENTION_TYPE] = "name"
134
+ if key in ambiguous:
135
+ properties[ENTITY_GROUP_CANDIDATES] = sorted((group_id[c] for c in ambiguous[key]), key=lambda g: int(g[1:]))
136
+ else:
137
+ properties.pop(ENTITY_GROUP_CANDIDATES, None)
138
+ a.properties = properties
139
+
140
+
141
+ def is_person_label(label: str, params: CoreferenceParameters) -> bool:
142
+ if params.person_labels:
143
+ return label in params.person_labels
144
+ return "person" in label.casefold()
145
+
146
+
147
+ def surface_of(a: Annotation, text: str) -> str:
148
+ return a.text if a.text is not None else text[a.start : a.end]
149
+
150
+
151
+ def name_tokens(surface: str, honorifics: set[str]) -> tuple[str, ...]:
152
+ """Normalized tokens of a surface: NFKC, one apostrophe, case-folded, and the
153
+ leading honorifics dropped (only for person names, where the set is not empty)."""
154
+ tokens = unicodedata.normalize("NFKC", surface).translate(APOSTROPHES).casefold().split()
155
+ while len(tokens) > 1 and tokens[0] in honorifics:
156
+ tokens = tokens[1:]
157
+ return tuple(tokens)
158
+
159
+
160
+ def strip_honorifics(surface: str, honorifics: set[str]) -> str:
161
+ words = re.sub(r"\s+", " ", surface).strip().split(" ")
162
+ while len(words) > 1 and words[0].casefold() in honorifics:
163
+ words = words[1:]
164
+ return " ".join(words)
165
+
166
+
167
+ def is_proper_suffix(tokens: tuple[str, ...], other: tuple[str, ...]) -> bool:
168
+ return len(tokens) < len(other) and other[len(other) - len(tokens) :] == tokens
169
+
170
+
171
+ def canonical_form(annotations: list[Annotation], text: str, honorifics: set[str]) -> str:
172
+ """The longest form of the group: most words, then most characters, then the first."""
173
+ forms = [strip_honorifics(surface_of(a, text), honorifics) for a in sorted(annotations, key=lambda a: a.start)]
174
+ return max(forms, key=lambda f: (len(f.split()), len(f)))
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "kairntech-python",
4
+ "description": "Les outils Claude Code de Kairntech, distribues avec l'archetype qu'ils decrivent.",
5
+ "owner": {
6
+ "name": "Kairntech",
7
+ "email": "olivier.terrier@kairntech.com"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "python-archetype",
12
+ "description": "Cree et entretient les depots Python qui consomment python-archetype : creation d'un plugin a partir de sa graine, et mesure de la derive.",
13
+ "author": { "name": "Kairntech" },
14
+ "category": "development",
15
+ "source": "."
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
+ "name": "python-archetype",
4
+ "displayName": "Kairntech python-archetype",
5
+ "version": "1.1.0",
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
+ "author": {
8
+ "name": "Kairntech",
9
+ "email": "olivier.terrier@kairntech.com"
10
+ },
11
+ "repository": "https://bitbucket.org/kairntech/python-archetype",
12
+ "license": "Proprietary",
13
+ "keywords": ["kairntech", "taskfile", "uv", "pyimporters", "pymultirole"],
14
+ "skills": "./skills/"
15
+ }