pyprocessors-coreference 1.6.1__py3-none-any.whl

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.
@@ -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,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,6 @@
1
+ pyprocessors_coreference/__init__.py,sha256=FhyAPdjVtDKqUrcvJaY5vUCeqGcWK2UlYYOf3nQ_yY0,80
2
+ pyprocessors_coreference/coreference.py,sha256=4rENXHvwliOCRMuO9BS-K9WUlLE55YaDGby-SDcgipI,7980
3
+ pyprocessors_coreference-1.6.1.dist-info/METADATA,sha256=7TrpLF88egi26V1467WL1RrYo_moKqGcrehg0Le2w7k,5660
4
+ pyprocessors_coreference-1.6.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ pyprocessors_coreference-1.6.1.dist-info/entry_points.txt,sha256=Ko1I0ZLLs3U0D2GDEZ_kzHN6OfsAjpwJNb7BCpWL-J4,95
6
+ pyprocessors_coreference-1.6.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pyprocessors.plugins]
2
+ coreference = pyprocessors_coreference.coreference:CoreferenceProcessor