pyannotators-entityfishing 1.6.99__tar.gz → 1.6.103__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyannotators_entityfishing-1.6.103/LICENSE +17 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/PKG-INFO +8 -3
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/README.md +4 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/pyproject.toml +2 -2
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/__init__.py +1 -1
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/.claude-plugin/plugin.json +1 -1
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/help_examples.py +49 -19
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/plugin-help.py +255 -57
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/plugin-help/SKILL.md +25 -9
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.claude/skills/ef-mapping/SKILL.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.gitignore +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.gitmodules +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.python-version +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/candidates.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/ef_client.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/entityfishing.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/HOWTO.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/README.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/Stages.yml +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/template.en.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/template.fr.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/annotator.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/converter.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/formatter.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/importer.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/processor.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/segmenter.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/types.md +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/tools/check-marketplace.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/build_rerank_data.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/check_rerank_endpoint.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/el_jev.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_linking.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_mappings.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_rerank.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/finetune_rerank.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/merge_rerank.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/recalibrate_linking.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.html +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/sherpa_plan_project.py +0 -0
- {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/wdfilter.py +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) 2026 Kairntech. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and its documentation are the proprietary and confidential
|
|
4
|
+
property of Kairntech. They are protected by copyright law and international
|
|
5
|
+
treaties.
|
|
6
|
+
|
|
7
|
+
No part of this software may be used, copied, modified, merged, published,
|
|
8
|
+
distributed, sublicensed or sold, in source or binary form, without the prior
|
|
9
|
+
written permission of Kairntech, except as expressly permitted by a separate
|
|
10
|
+
license agreement signed with Kairntech.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
14
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
15
|
+
KAIRNTECH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
16
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
17
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyannotators-entityfishing
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.103
|
|
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>
|
|
7
|
-
License:
|
|
7
|
+
License: Proprietary
|
|
8
|
+
License-File: LICENSE
|
|
8
9
|
Classifier: Development Status :: 4 - Beta
|
|
9
10
|
Classifier: Intended Audience :: Developers
|
|
10
11
|
Classifier: Intended Audience :: Information Technology
|
|
11
12
|
Classifier: Intended Audience :: System Administrators
|
|
12
|
-
Classifier: License ::
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
13
14
|
Classifier: Operating System :: OS Independent
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
16
|
Classifier: Topic :: Software Development
|
|
@@ -48,6 +49,10 @@ Description-Content-Type: text/markdown
|
|
|
48
49
|
|
|
49
50
|
# pyannotators-entityfishing
|
|
50
51
|
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
[](https://pypi.org/project/pyannotators-entityfishing/)
|
|
54
|
+
[](https://pypi.org/project/pyannotators-entityfishing/)
|
|
55
|
+
|
|
51
56
|
Annotator based on [entity-fishing](https://github.com/kermitt2/entity-fishing) for named entity recognition and disambiguation against Wikidata.
|
|
52
57
|
|
|
53
58
|
## Installation
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# pyannotators-entityfishing
|
|
2
2
|
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://pypi.org/project/pyannotators-entityfishing/)
|
|
5
|
+
[](https://pypi.org/project/pyannotators-entityfishing/)
|
|
6
|
+
|
|
3
7
|
Annotator based on [entity-fishing](https://github.com/kermitt2/entity-fishing) for named entity recognition and disambiguation against Wikidata.
|
|
4
8
|
|
|
5
9
|
## Installation
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Annotator based on entity-fishing"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
|
-
license = {text = "
|
|
11
|
+
license = {text = "Proprietary"}
|
|
12
12
|
authors = [
|
|
13
13
|
{name = "Olivier Terrier", email = "olivier.terrier@kairntech.com"},
|
|
14
14
|
]
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
24
|
"Topic :: Software Development :: Libraries",
|
|
25
25
|
"Topic :: Software Development",
|
|
26
|
-
"License ::
|
|
26
|
+
"License :: Other/Proprietary License",
|
|
27
27
|
"Development Status :: 4 - Beta",
|
|
28
28
|
"Programming Language :: Python :: 3.12",
|
|
29
29
|
]
|
|
@@ -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.3.
|
|
5
|
+
"version": "1.3.7",
|
|
6
6
|
"description": "Cree et entretient les depots Python de Kairntech qui consomment python-archetype : creation d'un plugin a partir de sa graine, page d'aide Sherpa d'un plugin, et mesure de la derive d'un depot par rapport a elle.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Kairntech",
|
|
@@ -26,6 +26,7 @@ Le dépôt du plugin rend la fixture visible depuis 'tests/conftest.py' :
|
|
|
26
26
|
|
|
27
27
|
import io
|
|
28
28
|
import json
|
|
29
|
+
import zipfile
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
|
|
31
32
|
import pytest
|
|
@@ -37,29 +38,53 @@ EXAMPLES_DIR = Path("build") / "help-examples"
|
|
|
37
38
|
MAX_CONTENT = 100_000
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
# Un classeur se montre par
|
|
41
|
+
# Un classeur se montre par ses feuilles : au-delà, la page n'en citerait qu'un extrait.
|
|
42
|
+
MAX_SHEETS = 5
|
|
41
43
|
MAX_SHEET_ROWS = 50
|
|
44
|
+
# Une archive se montre par la liste de ses fichiers, et le texte des premiers.
|
|
45
|
+
MAX_MEMBERS = 20
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
def
|
|
45
|
-
"""
|
|
46
|
-
if not data.startswith(b"PK"):
|
|
47
|
-
return None
|
|
48
|
+
def _sheets(data: bytes) -> list[dict] | None:
|
|
49
|
+
"""Les feuilles d'un classeur Excel, ou None si ce n'en est pas un (ou sans openpyxl)."""
|
|
48
50
|
try:
|
|
49
51
|
import openpyxl
|
|
50
52
|
|
|
51
53
|
workbook = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
|
52
54
|
except Exception: # noqa: BLE001 -- une archive zip qui n'est pas un classeur, ou pas d'openpyxl
|
|
53
55
|
return None
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
sheets = []
|
|
57
|
+
for sheet in workbook.worksheets[:MAX_SHEETS]:
|
|
58
|
+
rows = []
|
|
59
|
+
for row in sheet.iter_rows(values_only=True):
|
|
60
|
+
if len(rows) == MAX_SHEET_ROWS:
|
|
61
|
+
break
|
|
62
|
+
rows.append(["" if value is None else str(value) for value in row])
|
|
63
|
+
# Sans les colonnes vides de droite, qu'openpyxl rend quand une ligne est plus longue.
|
|
64
|
+
width = max((max((i + 1 for i, v in enumerate(row) if v), default=0) for row in rows), default=0)
|
|
65
|
+
sheets.append({"name": sheet.title, "rows": [row[:width] for row in rows]})
|
|
66
|
+
return sheets
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _archive(data: bytes) -> list[dict] | None:
|
|
70
|
+
"""Les fichiers d'une archive zip : nom, taille, et texte quand c'en est un."""
|
|
71
|
+
try:
|
|
72
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
73
|
+
except zipfile.BadZipFile:
|
|
74
|
+
return None
|
|
75
|
+
# Un document Office (docx, pptx) est une archive, mais ses fichiers XML n'apprennent rien au lecteur.
|
|
76
|
+
if "[Content_Types].xml" in archive.namelist():
|
|
77
|
+
return None
|
|
78
|
+
members = []
|
|
79
|
+
for info in archive.infolist()[:MAX_MEMBERS]:
|
|
80
|
+
if info.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
member = {"name": info.filename, "size": info.file_size}
|
|
83
|
+
decoded = _text(archive.read(info))
|
|
84
|
+
if decoded is not None:
|
|
85
|
+
member["text"] = decoded[0][:MAX_CONTENT]
|
|
86
|
+
members.append(member)
|
|
87
|
+
return members
|
|
63
88
|
|
|
64
89
|
|
|
65
90
|
def _text(data: bytes) -> tuple[str, str] | None:
|
|
@@ -79,12 +104,17 @@ def _text(data: bytes) -> tuple[str, str] | None:
|
|
|
79
104
|
|
|
80
105
|
|
|
81
106
|
def _content(data: bytes) -> dict:
|
|
82
|
-
"""Le contenu d'un fichier ou d'une réponse : son texte,
|
|
107
|
+
"""Le contenu d'un fichier ou d'une réponse : son texte, les feuilles d'un classeur, les fichiers d'une archive, ou sa taille."""
|
|
83
108
|
content = {"size": len(data)}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
if data.startswith(b"PK"):
|
|
110
|
+
sheets = _sheets(data)
|
|
111
|
+
if sheets is not None:
|
|
112
|
+
content["sheets"] = sheets
|
|
113
|
+
return content
|
|
114
|
+
members = _archive(data)
|
|
115
|
+
if members is not None:
|
|
116
|
+
content["archive"] = members
|
|
117
|
+
return content
|
|
88
118
|
decoded = _text(data)
|
|
89
119
|
if decoded is None:
|
|
90
120
|
return content
|
|
@@ -163,15 +163,27 @@ TEXTS = {
|
|
|
163
163
|
"sentences_removed": "Phrases en entrée retirées : toutes ({n}).",
|
|
164
164
|
"metadata_removed": "Métadonnées retirées :",
|
|
165
165
|
"sentences": "Phrases ({n}) :",
|
|
166
|
+
"sentences_in": "Phrases en entrée ({n}) :",
|
|
166
167
|
"empty_sentence": "*(phrase vide)*",
|
|
167
168
|
"produced_document": "Document produit",
|
|
168
169
|
"identifier_named": "identifiant",
|
|
169
170
|
"title": "Titre",
|
|
170
171
|
"properties": "Propriétés :",
|
|
171
172
|
"sheet": "Feuille `{name}`",
|
|
172
|
-
"first_sheet": "Feuille `{name}`, la première des {n}",
|
|
173
173
|
"encoding": "encodé en {encoding}",
|
|
174
174
|
"decimal": ",",
|
|
175
|
+
"list_sep": " ; ",
|
|
176
|
+
"empty_text": "*(texte vide)*",
|
|
177
|
+
"alt_texts_in": "Textes alternatifs en entrée :",
|
|
178
|
+
"alt_texts_out": "Textes alternatifs :",
|
|
179
|
+
"alt_texts_removed": "Textes alternatifs en entrée retirés : tous ({n}).",
|
|
180
|
+
"name": "Nom",
|
|
181
|
+
"properties_col": "Propriétés",
|
|
182
|
+
"display_label": "Libellé",
|
|
183
|
+
"archive": "Archive de {n} fichier(s) :",
|
|
184
|
+
"size": "Taille",
|
|
185
|
+
"bytes": "{n} octets",
|
|
186
|
+
"archive_member": "Fichier `{name}` de l'archive :",
|
|
175
187
|
},
|
|
176
188
|
"en": {
|
|
177
189
|
"languages": "**Supported languages:**",
|
|
@@ -224,15 +236,27 @@ TEXTS = {
|
|
|
224
236
|
"sentences_removed": "Input sentences removed: all of them ({n}).",
|
|
225
237
|
"metadata_removed": "Metadata removed:",
|
|
226
238
|
"sentences": "Sentences ({n}):",
|
|
239
|
+
"sentences_in": "Input sentences ({n}):",
|
|
227
240
|
"empty_sentence": "*(empty sentence)*",
|
|
228
241
|
"produced_document": "Produced document",
|
|
229
242
|
"identifier_named": "identifier",
|
|
230
243
|
"title": "Title",
|
|
231
244
|
"properties": "Properties:",
|
|
232
245
|
"sheet": "Sheet `{name}`",
|
|
233
|
-
"first_sheet": "Sheet `{name}`, the first of {n}",
|
|
234
246
|
"encoding": "encoded in {encoding}",
|
|
235
247
|
"decimal": ".",
|
|
248
|
+
"list_sep": "; ",
|
|
249
|
+
"empty_text": "*(empty text)*",
|
|
250
|
+
"alt_texts_in": "Input alternative texts:",
|
|
251
|
+
"alt_texts_out": "Alternative texts:",
|
|
252
|
+
"alt_texts_removed": "Input alternative texts removed: all of them ({n}).",
|
|
253
|
+
"name": "Name",
|
|
254
|
+
"properties_col": "Properties",
|
|
255
|
+
"display_label": "Display label",
|
|
256
|
+
"archive": "Archive of {n} file(s):",
|
|
257
|
+
"size": "Size",
|
|
258
|
+
"bytes": "{n} bytes",
|
|
259
|
+
"archive_member": "File `{name}` in the archive:",
|
|
236
260
|
},
|
|
237
261
|
}
|
|
238
262
|
|
|
@@ -270,7 +294,7 @@ def languages_block(codes: list[str], lang: str) -> str:
|
|
|
270
294
|
return f"{texts['languages']} {names}.\n"
|
|
271
295
|
|
|
272
296
|
|
|
273
|
-
def format_default(prop: dict, lang: str) -> str | None:
|
|
297
|
+
def format_default(prop: dict, lang: str, max_len: int | None = None) -> str | None:
|
|
274
298
|
"""La valeur par défaut affichée, ou None quand le code n'en donne pas d'utile.
|
|
275
299
|
|
|
276
300
|
None couvre le défaut 'None' du code, qui veut presque toujours dire « valeur calculée »
|
|
@@ -286,9 +310,52 @@ def format_default(prop: dict, lang: str) -> str | None:
|
|
|
286
310
|
return texts["true"] if value else texts["false"]
|
|
287
311
|
if value == "":
|
|
288
312
|
return texts["empty"]
|
|
313
|
+
text = one_line(value)
|
|
314
|
+
# Tronquée au-delà de 'max_len' caractères, accents graves compris (la cellule d'un tableau).
|
|
315
|
+
if max_len is not None and len(text) + 2 > max_len:
|
|
316
|
+
text = text[: max_len - 2].rstrip() + " …"
|
|
317
|
+
return inline_code(text)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def inline_code(text: str) -> str:
|
|
321
|
+
"""Du code en ligne, délimité par plus d'accents graves que n'en contient le texte (CommonMark)."""
|
|
322
|
+
runs = [len(run) for run in re.findall(r"`+", text)]
|
|
323
|
+
fence = "`" * (max(runs, default=0) + 1)
|
|
324
|
+
pad = " " if text.startswith("`") or text.endswith("`") else ""
|
|
325
|
+
return f"{fence}{pad}{text}{pad}{fence}"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def one_line(value) -> str:
|
|
329
|
+
"""Une valeur sur une ligne : une ligne de tableau ne survit pas à un retour à la ligne.
|
|
330
|
+
|
|
331
|
+
Une chaîne qui contient du JSON (une règle, un mapping) est montrée comme du JSON compact,
|
|
332
|
+
pas échappée ; les autres caractères de contrôle s'écrivent comme dans le code (\\n, \\t).
|
|
333
|
+
"""
|
|
334
|
+
if isinstance(value, str):
|
|
335
|
+
stripped = value.strip()
|
|
336
|
+
if stripped[:1] in "[{":
|
|
337
|
+
try:
|
|
338
|
+
return json.dumps(json.loads(stripped), ensure_ascii=False)
|
|
339
|
+
except ValueError:
|
|
340
|
+
pass
|
|
341
|
+
return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
289
342
|
if isinstance(value, (list, dict)):
|
|
290
|
-
return
|
|
291
|
-
return
|
|
343
|
+
return json.dumps(decode_json_strings(value), ensure_ascii=False)
|
|
344
|
+
return str(value)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def decode_json_strings(value):
|
|
348
|
+
# Un dict de chaînes JSON (un mapping par label, par exemple) se lit mieux décodé.
|
|
349
|
+
if isinstance(value, dict):
|
|
350
|
+
return {key: decode_json_strings(item) for key, item in value.items()}
|
|
351
|
+
if isinstance(value, list):
|
|
352
|
+
return [decode_json_strings(item) for item in value]
|
|
353
|
+
if isinstance(value, str) and value.strip()[:1] in "[{":
|
|
354
|
+
try:
|
|
355
|
+
return json.loads(value)
|
|
356
|
+
except ValueError:
|
|
357
|
+
return value
|
|
358
|
+
return value
|
|
292
359
|
|
|
293
360
|
|
|
294
361
|
def escape(cell: str) -> str:
|
|
@@ -327,7 +394,9 @@ def parameters_block(schema: dict, old_body: str, lang: str) -> str:
|
|
|
327
394
|
if "advanced" in extra:
|
|
328
395
|
first += f" *({texts['advanced']})*"
|
|
329
396
|
previous = old.get(name)
|
|
330
|
-
|
|
397
|
+
# Une valeur longue (un jeu de règles JSON) écraserait le tableau : son début suffit,
|
|
398
|
+
# le reste est dans le code et, au besoin, dans la prose.
|
|
399
|
+
default = format_default(prop, lang, max_len=MAX_DEFAULT)
|
|
331
400
|
if default is None:
|
|
332
401
|
default = previous[1] if previous else "—"
|
|
333
402
|
else:
|
|
@@ -345,7 +414,11 @@ def parameters_line(params: dict, schema: dict, lang: str) -> str:
|
|
|
345
414
|
properties = schema.get("properties", {})
|
|
346
415
|
used = []
|
|
347
416
|
# Dans l'ordre du schéma, celui de l'interface ; un paramètre inconnu du schéma, à la fin.
|
|
348
|
-
|
|
417
|
+
# Un paramètre interne (la langue du projet, par exemple) est fixé par Sherpa, pas par l'utilisateur.
|
|
418
|
+
shown_params = [name for name in params if "internal" not in str(properties.get(name, {}).get("extra", ""))]
|
|
419
|
+
if not shown_params:
|
|
420
|
+
return texts["default_parameters"]
|
|
421
|
+
for name in sorted(shown_params, key=lambda n: list(properties).index(n) if n in properties else len(properties)):
|
|
349
422
|
value = params[name]
|
|
350
423
|
title = properties.get(name, {}).get("title") or name
|
|
351
424
|
shown = format_default({"default": value}, lang) or "`None`"
|
|
@@ -357,15 +430,30 @@ def quote(text: str) -> str:
|
|
|
357
430
|
return "\n".join(f"> {line}".rstrip() for line in text.strip().splitlines())
|
|
358
431
|
|
|
359
432
|
|
|
360
|
-
|
|
433
|
+
# Une balise ou une entité : Sherpa interpréterait le HTML d'une citation ou d'une cellule.
|
|
434
|
+
MARKUP = re.compile(r"<[A-Za-z/!?]|&#?\w+;")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def code(text: str) -> str:
|
|
438
|
+
"""Un nom technique, à mettre entre accents graves : rien n'y est interprété, sauf la barre."""
|
|
361
439
|
return escape(" ".join(text.split()))
|
|
362
440
|
|
|
363
441
|
|
|
442
|
+
def cell(text: str) -> str:
|
|
443
|
+
# '<' et '&' échappés : une balise produite par le plugin (<personne>) se lit au lieu de disparaître.
|
|
444
|
+
return escape(" ".join(text.split()).replace("&", "&").replace("<", "<"))
|
|
445
|
+
|
|
446
|
+
|
|
364
447
|
# Une page montre un extrait, pas un document entier.
|
|
365
448
|
MAX_TEXT = 1000
|
|
366
449
|
MAX_LINES = 20
|
|
367
450
|
MAX_LINE = 160
|
|
368
451
|
MAX_ROWS = 20
|
|
452
|
+
MAX_JSON_LINES = 60
|
|
453
|
+
# Un fichier XML ou HTML dit l'essentiel après son en-tête : il en faut plus de MAX_LINES.
|
|
454
|
+
MAX_MARKUP_LINES = 60
|
|
455
|
+
MAX_CELL = 80
|
|
456
|
+
MAX_DEFAULT = 100
|
|
369
457
|
|
|
370
458
|
|
|
371
459
|
def as_list(value) -> list:
|
|
@@ -395,8 +483,15 @@ LAYOUT = re.compile(r"^[ \t]+\S|\S[ \t]*\n[ \t]*\S|\S {2,}\S|\t", re.MULTILINE)
|
|
|
395
483
|
|
|
396
484
|
def excerpt(text: str, lang: str) -> list[str]:
|
|
397
485
|
"""Le texte, cité ; dans un bloc de code quand sa mise en page compte, pour qu'on la voie."""
|
|
486
|
+
if not text.strip():
|
|
487
|
+
return [TEXTS[lang]["empty_text"]]
|
|
398
488
|
shown = text if len(text) <= MAX_TEXT else text[:MAX_TEXT].rsplit(" ", 1)[0] + " …"
|
|
399
|
-
|
|
489
|
+
# Des lignes vides en tête comptent (elles décalent les positions) : le bloc de code les garde.
|
|
490
|
+
leading = shown.lstrip(" \t").startswith("\n")
|
|
491
|
+
if leading or MARKUP.search(text) or LAYOUT.search(text.strip("\n")):
|
|
492
|
+
parts = code_block(shown, "text", lang, max_line=None, keep_leading=leading)
|
|
493
|
+
else:
|
|
494
|
+
parts = [quote(shown)]
|
|
400
495
|
if len(text) > MAX_TEXT:
|
|
401
496
|
parts.append(TEXTS[lang]["truncated_text"].format(n=len(text)))
|
|
402
497
|
return parts
|
|
@@ -406,14 +501,24 @@ def number(value: float, lang: str) -> str:
|
|
|
406
501
|
return f"{value:.2f}".replace(".", TEXTS[lang]["decimal"])
|
|
407
502
|
|
|
408
503
|
|
|
409
|
-
def code_block(text: str, kind: str, lang: str, max_line: int | None = MAX_LINE) -> list[str]:
|
|
410
|
-
"""Un extrait de contenu dans un bloc de code ; 'kind' est un type MIME ou un nom de fichier.
|
|
504
|
+
def code_block(text: str, kind: str, lang: str, max_line: int | None = MAX_LINE, keep_leading: bool = False) -> list[str]:
|
|
505
|
+
"""Un extrait de contenu dans un bloc de code ; 'kind' est un type MIME ou un nom de fichier.
|
|
506
|
+
|
|
507
|
+
'keep_leading' : garder les lignes vides du début, que le texte montré doit faire voir.
|
|
508
|
+
"""
|
|
411
509
|
fence_lang = next((name for name in ("json", "xml", "html", "csv", "markdown") if name in kind.lower()), "text")
|
|
412
|
-
|
|
413
|
-
|
|
510
|
+
if fence_lang == "json":
|
|
511
|
+
# Un JSON sur une ligne serait coupé à MAX_LINE : réindenté, il se lit, et tient en MAX_JSON_LINES.
|
|
512
|
+
try:
|
|
513
|
+
text = json.dumps(json.loads(text), ensure_ascii=False, indent=2)
|
|
514
|
+
except ValueError:
|
|
515
|
+
pass
|
|
516
|
+
max_lines = {"json": MAX_JSON_LINES, "xml": MAX_MARKUP_LINES, "html": MAX_MARKUP_LINES}.get(fence_lang, MAX_LINES)
|
|
517
|
+
lines = (text.rstrip("\n") if keep_leading else text.strip("\n")).splitlines()
|
|
518
|
+
shown = [line if max_line is None or len(line) <= max_line else line[:max_line] + " …" for line in lines[:max_lines]]
|
|
414
519
|
fence = "~~~~" if "```" in text else "```"
|
|
415
520
|
parts = [f"{fence}{fence_lang}\n" + "\n".join(shown) + f"\n{fence}"]
|
|
416
|
-
if len(lines) >
|
|
521
|
+
if len(lines) > max_lines:
|
|
417
522
|
parts.append(TEXTS[lang]["truncated_lines"].format(n=len(lines)))
|
|
418
523
|
return parts
|
|
419
524
|
|
|
@@ -433,6 +538,9 @@ def file_lead(source: dict, lang: str) -> list[str]:
|
|
|
433
538
|
return [lead, *content_view(source, media_type or source.get("filename", ""), lang)]
|
|
434
539
|
|
|
435
540
|
|
|
541
|
+
MAX_MEMBERS = 3
|
|
542
|
+
|
|
543
|
+
|
|
436
544
|
def column_name(index: int) -> str:
|
|
437
545
|
"""A, B, …, Z, AA, AB… : le nom d'une colonne Excel."""
|
|
438
546
|
name = ""
|
|
@@ -444,22 +552,32 @@ def column_name(index: int) -> str:
|
|
|
444
552
|
|
|
445
553
|
|
|
446
554
|
def content_view(content: dict, kind: str, lang: str) -> list[str]:
|
|
447
|
-
"""Le contenu d'un fichier ou d'une réponse : son texte,
|
|
555
|
+
"""Le contenu d'un fichier ou d'une réponse : son texte, les feuilles d'un classeur, les fichiers d'une archive, ou sa taille."""
|
|
448
556
|
texts = TEXTS[lang]
|
|
449
557
|
if "text" in content:
|
|
450
558
|
return code_block(content["text"], kind, lang)
|
|
451
|
-
if "
|
|
559
|
+
if "sheets" in content:
|
|
452
560
|
# La taille d'un classeur dépend de la version d'openpyxl : on montre ses cellules.
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
561
|
+
parts = []
|
|
562
|
+
for sheet in content["sheets"]:
|
|
563
|
+
lead = texts["sheet"].format(name=code(sheet.get("name", "")))
|
|
564
|
+
rows = [[cell(value) or " " for value in row] for row in sheet.get("rows", [])]
|
|
565
|
+
if not rows:
|
|
566
|
+
parts += [lead + texts["colon"], texts["empty"]]
|
|
567
|
+
continue
|
|
568
|
+
width = max(len(row) for row in rows)
|
|
569
|
+
rows = [row + [" "] * (width - len(row)) for row in rows]
|
|
570
|
+
# Les colonnes nommées comme dans Excel : rien ne dit si la première ligne est un en-tête.
|
|
571
|
+
parts += [lead + texts["colon"], table([column_name(i) for i in range(width)], rows, lang)]
|
|
572
|
+
return parts
|
|
573
|
+
if "archive" in content:
|
|
574
|
+
members = content["archive"]
|
|
575
|
+
rows = [[f"`{code(m['name'])}`", texts["bytes"].format(n=m["size"])] for m in members]
|
|
576
|
+
parts = [texts["archive"].format(n=len(members)), table([texts["name"], texts["size"]], rows, lang)]
|
|
577
|
+
# Le contenu des premiers fichiers lisibles : c'est lui que le plugin lit.
|
|
578
|
+
for member in [m for m in members if "text" in m][:MAX_MEMBERS]:
|
|
579
|
+
parts += [texts["archive_member"].format(name=code(member["name"])), *code_block(member["text"], member["name"], lang)]
|
|
580
|
+
return parts
|
|
463
581
|
if "size" in content:
|
|
464
582
|
return [texts["binary"].format(n=content["size"])]
|
|
465
583
|
return []
|
|
@@ -470,36 +588,72 @@ def annotations_table(annotations: list[dict], lang: str, doc_text: str = "") ->
|
|
|
470
588
|
# Une annotation sans 'text' (le champ est facultatif) se lit dans le document, par ses offsets.
|
|
471
589
|
annotations = [a if a.get("text") else {**a, "text": doc_text[a["start"] : a["end"]]} for a in annotations]
|
|
472
590
|
annotations = sorted(annotations, key=lambda a: (a["start"], a["end"]))
|
|
473
|
-
# Un même texte
|
|
474
|
-
|
|
591
|
+
# Un même texte trouvé à plusieurs endroits est numéroté, dans l'ordre du document ; deux
|
|
592
|
+
# annotations du même passage (deux labels sur « London ») portent le même numéro.
|
|
593
|
+
spans: dict[str, list[tuple[int, int]]] = {}
|
|
475
594
|
for a in annotations:
|
|
476
|
-
|
|
595
|
+
positions = spans.setdefault(a.get("text", ""), [])
|
|
596
|
+
if (a["start"], a["end"]) not in positions:
|
|
597
|
+
positions.append((a["start"], a["end"]))
|
|
477
598
|
with_terms = any(a.get("terms") for a in annotations)
|
|
599
|
+
with_properties = any(a.get("properties") for a in annotations)
|
|
600
|
+
# Le lexique d'un terme se montre quand il y en a plusieurs : c'est alors lui qui distingue.
|
|
601
|
+
lexicons = {t.get("lexicon") for a in annotations for t in a.get("terms") or [] if t.get("lexicon")}
|
|
478
602
|
header = [texts["annotated"], texts["label"]] + ([texts["preferred"]] if with_terms else [])
|
|
603
|
+
with_score = any(isinstance(a.get("score"), (int, float)) for a in annotations)
|
|
604
|
+
header += [texts["score"]] if with_score else []
|
|
605
|
+
header += [texts["properties_col"]] if with_properties else []
|
|
479
606
|
rows = []
|
|
480
|
-
seen: dict[str, int] = {}
|
|
481
607
|
for a in annotations:
|
|
482
608
|
text = a.get("text", "")
|
|
483
|
-
seen[text] = seen.get(text, 0) + 1
|
|
484
609
|
shown = cell(text)
|
|
485
|
-
if
|
|
486
|
-
shown += f" *({texts['occurrence'].format(n=
|
|
487
|
-
row = [shown, f"`{
|
|
610
|
+
if len(spans[text]) > 1:
|
|
611
|
+
shown += f" *({texts['occurrence'].format(n=spans[text].index((a['start'], a['end'])) + 1)})*"
|
|
612
|
+
row = [shown, f"`{code(a.get('labelName') or a.get('label') or '')}`"]
|
|
488
613
|
if with_terms:
|
|
489
|
-
#
|
|
490
|
-
forms =
|
|
614
|
+
# Dans l'ordre du plugin (celui des candidats a un sens), sans doublon.
|
|
615
|
+
forms = []
|
|
616
|
+
for t in a.get("terms") or []:
|
|
617
|
+
form = t.get("preferredForm") or t.get("identifier", "")
|
|
618
|
+
if len(lexicons) > 1 and t.get("lexicon"):
|
|
619
|
+
form += f" ({t['lexicon']})"
|
|
620
|
+
if form not in forms:
|
|
621
|
+
forms.append(form)
|
|
491
622
|
row.append(cell(" / ".join(forms)) or "—")
|
|
623
|
+
if with_score:
|
|
624
|
+
row.append(number(a["score"], lang) if isinstance(a.get("score"), (int, float)) else "—")
|
|
625
|
+
if with_properties:
|
|
626
|
+
row.append(properties_cell(a.get("properties") or {}, lang))
|
|
492
627
|
rows.append(row)
|
|
493
628
|
return table(header, rows, lang)
|
|
494
629
|
|
|
495
630
|
|
|
631
|
+
def properties_cell(properties: dict, lang: str) -> str:
|
|
632
|
+
"""Des propriétés dans une cellule : `clé` : valeur, triées par clé, chaque valeur sur une ligne."""
|
|
633
|
+
if not properties:
|
|
634
|
+
return "—"
|
|
635
|
+
shown = []
|
|
636
|
+
for key in sorted(properties):
|
|
637
|
+
value = properties[key]
|
|
638
|
+
value = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
639
|
+
# Coupée avant d'être échappée : une entité (<) ne doit pas être tranchée.
|
|
640
|
+
value = " ".join(value.split())
|
|
641
|
+
value = cell(value) if len(value) <= MAX_CELL else cell(value[:MAX_CELL]) + " …"
|
|
642
|
+
shown.append(f"`{code(key)}`{TEXTS[lang]['colon']} {value}")
|
|
643
|
+
return "<br>".join(shown)
|
|
644
|
+
|
|
645
|
+
|
|
496
646
|
def categories_table(categories: list[dict], lang: str) -> str:
|
|
497
647
|
texts = TEXTS[lang]
|
|
498
648
|
with_score = any("score" in c for c in categories)
|
|
499
|
-
|
|
649
|
+
# Le libellé que montre Sherpa, quand il n'est pas le nom technique (un code IPTC, par exemple).
|
|
650
|
+
with_display = any(c.get("label") and c.get("labelName") and c["label"] != c["labelName"] for c in categories)
|
|
651
|
+
header = [texts["label"]] + ([texts["display_label"]] if with_display else []) + ([texts["score"]] if with_score else [])
|
|
500
652
|
rows = []
|
|
501
653
|
for c in categories:
|
|
502
|
-
row = [f"`{
|
|
654
|
+
row = [f"`{code(c.get('labelName') or c.get('label') or '')}`"]
|
|
655
|
+
if with_display:
|
|
656
|
+
row.append(cell(c.get("label") or "") or "—")
|
|
503
657
|
if with_score:
|
|
504
658
|
row.append(number(c["score"], lang) if isinstance(c.get("score"), (int, float)) else "—")
|
|
505
659
|
rows.append(row)
|
|
@@ -508,18 +662,36 @@ def categories_table(categories: list[dict], lang: str) -> str:
|
|
|
508
662
|
|
|
509
663
|
def metadata_table(metadata: dict, lang: str) -> str:
|
|
510
664
|
texts = TEXTS[lang]
|
|
511
|
-
rows = [[f"`{
|
|
665
|
+
rows = [[f"`{code(key)}`", cell(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False))] for key, value in metadata.items()]
|
|
512
666
|
return table([texts["key"], texts["value"]], rows, lang)
|
|
513
667
|
|
|
514
668
|
|
|
669
|
+
def alt_texts_table(alt_texts: list[dict], lang: str) -> str:
|
|
670
|
+
texts = TEXTS[lang]
|
|
671
|
+
rows = []
|
|
672
|
+
for alt in alt_texts:
|
|
673
|
+
value = " ".join(alt.get("text", "").split())
|
|
674
|
+
value = cell(value) if len(value) <= 2 * MAX_CELL else cell(value[: 2 * MAX_CELL]) + " …"
|
|
675
|
+
rows.append([f"`{code(alt.get('name', ''))}`", value or texts["empty_text"]])
|
|
676
|
+
return table([texts["name"], texts["text"]], rows, lang)
|
|
677
|
+
|
|
678
|
+
|
|
515
679
|
def input_view(doc: dict, lang: str) -> list[str]:
|
|
516
680
|
"""Un document en entrée : son texte, et ce qu'il porte déjà, que le plugin va lire."""
|
|
517
681
|
texts = TEXTS[lang]
|
|
518
682
|
parts = text_lead(doc, lang)
|
|
683
|
+
# Le découpage en entrée, sauf la phrase unique qui couvre tout le texte : elle n'apprend rien.
|
|
684
|
+
sentences = doc.get("sentences") or []
|
|
685
|
+
whole = len(sentences) == 1 and not sentences[0].get("metadata") and not sentences[0].get("categories")
|
|
686
|
+
whole = whole and doc.get("text", "")[: sentences[0]["start"]].strip() == "" and doc.get("text", "")[sentences[0]["end"] :].strip() == ""
|
|
687
|
+
if sentences and not whole:
|
|
688
|
+
parts += [texts["sentences_in"].format(n=len(sentences)), *sentences_list(doc, lang)]
|
|
519
689
|
if doc.get("annotations"):
|
|
520
690
|
parts += [texts["annotations_in"], annotations_table(doc["annotations"], lang, doc.get("text", ""))]
|
|
521
691
|
if doc.get("categories"):
|
|
522
692
|
parts += [texts["categories_in"], categories_table(doc["categories"], lang)]
|
|
693
|
+
if doc.get("altTexts"):
|
|
694
|
+
parts += [texts["alt_texts_in"], alt_texts_table(doc["altTexts"], lang)]
|
|
523
695
|
return parts
|
|
524
696
|
|
|
525
697
|
|
|
@@ -528,9 +700,19 @@ MARKDOWN_LEAD = re.compile(r"^([-+#>])")
|
|
|
528
700
|
MARKDOWN_NUMBER = re.compile(r"^(\d+)([.)])")
|
|
529
701
|
|
|
530
702
|
|
|
703
|
+
# Un retour à la ligne dans une phrase : sur une ligne de liste, il serait un simple espace.
|
|
704
|
+
# « ↵ » pour un retour à la ligne, « ↵↵ » pour une ligne vide ou plus.
|
|
705
|
+
NEWLINES = re.compile(r"[ \t]*(?:\r?\n[ \t]*)+")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def mark_newlines(text: str) -> str:
|
|
709
|
+
return NEWLINES.sub(lambda m: " ↵ " if m.group().count("\n") == 1 else " ↵↵ ", text.strip())
|
|
710
|
+
|
|
711
|
+
|
|
531
712
|
def sentence_text(text: str, sent: dict, lang: str, limit: int | None = None) -> str:
|
|
532
713
|
# Échappée : une phrase « * Conseil » ou « 1. Bilan » deviendrait une liste dans la liste.
|
|
533
|
-
phrase =
|
|
714
|
+
phrase = mark_newlines(text[sent["start"] : sent["end"]])
|
|
715
|
+
phrase = MARKDOWN_INLINE.sub(r"\\\1", cell(phrase))
|
|
534
716
|
phrase = MARKDOWN_NUMBER.sub(r"\1\\\2", MARKDOWN_LEAD.sub(r"\\\1", phrase))
|
|
535
717
|
if not phrase:
|
|
536
718
|
return TEXTS[lang]["empty_sentence"]
|
|
@@ -544,7 +726,14 @@ def sentences_list(doc: dict, lang: str) -> list[str]:
|
|
|
544
726
|
if not sentences:
|
|
545
727
|
return [texts["no_sentences"]]
|
|
546
728
|
text = doc.get("text", "")
|
|
547
|
-
|
|
729
|
+
items = []
|
|
730
|
+
for n, s in enumerate(sentences[:MAX_ROWS], start=1):
|
|
731
|
+
item = f"{n}. {sentence_text(text, s, lang)}"
|
|
732
|
+
# Les métadonnées d'une phrase (sa section, sa page…) sont souvent tout ce qu'en fait le plugin.
|
|
733
|
+
if s.get("metadata"):
|
|
734
|
+
item += " — " + properties_cell(s["metadata"], lang).replace("<br>", TEXTS[lang]["list_sep"])
|
|
735
|
+
items.append(item)
|
|
736
|
+
parts = ["\n".join(items)]
|
|
548
737
|
if len(sentences) > MAX_ROWS:
|
|
549
738
|
parts.append(texts["truncated_rows"].format(n=len(sentences)))
|
|
550
739
|
return parts
|
|
@@ -560,18 +749,21 @@ def sentence_categories(doc: dict, lang: str) -> list[str]:
|
|
|
560
749
|
continue
|
|
561
750
|
labels = []
|
|
562
751
|
for c in sent["categories"]:
|
|
563
|
-
label = f"`{
|
|
752
|
+
label = f"`{code(c.get('labelName') or c.get('label') or '')}`"
|
|
564
753
|
labels.append(label + (f" ({number(c['score'], lang)})" if isinstance(c.get("score"), (int, float)) else ""))
|
|
565
754
|
rows.append([sentence_text(text, sent, lang, limit=80), ", ".join(labels)])
|
|
566
755
|
return [texts["sentence_categories"], table([texts["sentence"], texts["categories"]], rows, lang)] if rows else []
|
|
567
756
|
|
|
568
757
|
|
|
569
|
-
def document_lead(doc: dict, lang: str) -> list[str]:
|
|
570
|
-
"""Un document produit sans document en entrée (un convertisseur) : identifiant, titre, propriétés.
|
|
758
|
+
def document_lead(doc: dict, lang: str, numbered: bool = False) -> list[str]:
|
|
759
|
+
"""Un document produit sans document en entrée (un convertisseur) : identifiant, titre, propriétés.
|
|
760
|
+
|
|
761
|
+
'numbered' : l'en-tête « Document n°N (`id`) » a déjà donné l'identifiant.
|
|
762
|
+
"""
|
|
571
763
|
texts = TEXTS[lang]
|
|
572
764
|
lead = texts["produced_document"]
|
|
573
|
-
if doc.get("identifier"):
|
|
574
|
-
lead += f", {texts['identifier_named']} `{
|
|
765
|
+
if doc.get("identifier") and not numbered:
|
|
766
|
+
lead += f", {texts['identifier_named']} `{code(doc['identifier'])}`"
|
|
575
767
|
parts = [lead + "."]
|
|
576
768
|
if doc.get("title"):
|
|
577
769
|
parts.append(f"{texts['title']}{texts['colon']} {cell(doc['title'])}")
|
|
@@ -580,10 +772,10 @@ def document_lead(doc: dict, lang: str) -> list[str]:
|
|
|
580
772
|
return parts
|
|
581
773
|
|
|
582
774
|
|
|
583
|
-
def output_view(doc: dict, before: dict | None, lang: str) -> list[str]:
|
|
775
|
+
def output_view(doc: dict, before: dict | None, lang: str, numbered: bool = False) -> list[str]:
|
|
584
776
|
"""Un document en sortie : ce qui a changé par rapport à l'entrée, ce qui a été retiré compris."""
|
|
585
777
|
texts = TEXTS[lang]
|
|
586
|
-
parts = document_lead(doc, lang) if before is None else []
|
|
778
|
+
parts = document_lead(doc, lang, numbered) if before is None else []
|
|
587
779
|
before = before or {}
|
|
588
780
|
if doc.get("text", "") != before.get("text", ""):
|
|
589
781
|
parts += [texts["produced_text"], *excerpt(doc.get("text", ""), lang)]
|
|
@@ -599,16 +791,17 @@ def output_view(doc: dict, before: dict | None, lang: str) -> list[str]:
|
|
|
599
791
|
parts.append(texts["sentences_removed"].format(n=len(before["sentences"])))
|
|
600
792
|
# Une liste qui disparaît se dit : sans quoi un processeur qui vide le document paraîtrait inactif.
|
|
601
793
|
tables = {
|
|
602
|
-
"annotations": lambda: annotations_table(doc["annotations"], lang, doc.get("text", "")),
|
|
603
|
-
"categories": lambda: categories_table(doc["categories"], lang),
|
|
794
|
+
"annotations": ("annotations", lambda: annotations_table(doc["annotations"], lang, doc.get("text", ""))),
|
|
795
|
+
"categories": ("categories", lambda: categories_table(doc["categories"], lang)),
|
|
796
|
+
"altTexts": ("alt_texts", lambda: alt_texts_table(doc["altTexts"], lang)),
|
|
604
797
|
}
|
|
605
|
-
for key, table_of in tables.items():
|
|
798
|
+
for key, (name, table_of) in tables.items():
|
|
606
799
|
if (doc.get(key) or []) == (before.get(key) or []):
|
|
607
800
|
continue
|
|
608
801
|
if doc.get(key):
|
|
609
|
-
parts += [texts[f"{
|
|
802
|
+
parts += [texts[f"{name}_out"], table_of()]
|
|
610
803
|
else:
|
|
611
|
-
parts.append(texts[f"{
|
|
804
|
+
parts.append(texts[f"{name}_removed"].format(n=len(before[key])))
|
|
612
805
|
old_metadata = before.get("metadata") or {}
|
|
613
806
|
metadata = doc.get("metadata") or {}
|
|
614
807
|
changed = {key: value for key, value in metadata.items() if old_metadata.get(key) != value}
|
|
@@ -616,7 +809,7 @@ def output_view(doc: dict, before: dict | None, lang: str) -> list[str]:
|
|
|
616
809
|
parts += [texts["metadata_out"], metadata_table(changed, lang)]
|
|
617
810
|
removed = [key for key in old_metadata if key not in metadata]
|
|
618
811
|
if removed:
|
|
619
|
-
parts.append(f"{texts['metadata_removed']} " + ", ".join(f"`{
|
|
812
|
+
parts.append(f"{texts['metadata_removed']} " + ", ".join(f"`{code(key)}`" for key in removed) + ".")
|
|
620
813
|
return parts or [texts["unchanged"]]
|
|
621
814
|
|
|
622
815
|
|
|
@@ -631,7 +824,7 @@ def documents_view(outputs: list[dict], inputs: list[dict] | None, lang: str) ->
|
|
|
631
824
|
parts = []
|
|
632
825
|
for n, (doc, before) in enumerate(zip(outputs, paired, strict=True), start=1):
|
|
633
826
|
identifier = f" (`{doc['identifier']}`)" if doc.get("identifier") else ""
|
|
634
|
-
parts += [f"**{texts['document'].format(n=n)}**{identifier}", *output_view(doc, before, lang)]
|
|
827
|
+
parts += [f"**{texts['document'].format(n=n)}**{identifier}", *output_view(doc, before, lang, numbered=True)]
|
|
635
828
|
return parts
|
|
636
829
|
|
|
637
830
|
|
|
@@ -700,13 +893,18 @@ def importer_example(example: dict, schema: dict, lang: str) -> str:
|
|
|
700
893
|
parts.append(texts["no_concepts"])
|
|
701
894
|
return "\n\n".join(parts) + "\n"
|
|
702
895
|
with_alt = any((c.get("properties") or {}).get("altForms") for c in concepts)
|
|
896
|
+
others = [{k: v for k, v in (c.get("properties") or {}).items() if k != "altForms"} for c in concepts]
|
|
897
|
+
with_others = any(others)
|
|
703
898
|
header = [texts["identifier"], texts["preferred"]] + ([texts["alt_forms"]] if with_alt else [])
|
|
899
|
+
header += [texts["properties_col"]] if with_others else []
|
|
704
900
|
rows = []
|
|
705
|
-
for c in concepts:
|
|
706
|
-
row = [f"`{
|
|
901
|
+
for c, other in zip(concepts, others, strict=True):
|
|
902
|
+
row = [f"`{code(c.get('identifier', ''))}`", cell(c.get("preferredForm") or "") or "—"]
|
|
707
903
|
if with_alt:
|
|
708
904
|
alt = (c.get("properties") or {}).get("altForms") or []
|
|
709
905
|
row.append(cell(", ".join(as_list(alt))) or "—")
|
|
906
|
+
if with_others:
|
|
907
|
+
row.append(properties_cell(other, lang))
|
|
710
908
|
rows.append(row)
|
|
711
909
|
parts += [texts["concepts_count"].format(n=len(concepts)), table(header, rows, lang)]
|
|
712
910
|
return "\n\n".join(parts) + "\n"
|
|
@@ -114,12 +114,12 @@ que le test passe à `help_example` aussi :
|
|
|
114
114
|
|
|
115
115
|
| type | `inputs` | `outputs` | ce que la page montre |
|
|
116
116
|
|------|----------|-----------|------------------------|
|
|
117
|
-
| annotateur | les documents | les documents | le texte, puis les annotations |
|
|
118
|
-
| processeur | les documents | les documents | le texte et ce qu'il porte
|
|
119
|
-
| segmenteur | les documents | les documents | le texte, puis les phrases numérotées |
|
|
117
|
+
| annotateur | les documents | les documents | le texte, puis les annotations : label, termes (dans l'ordre du plugin, avec leur lexique s'il y en a plusieurs), score et propriétés |
|
|
118
|
+
| processeur | les documents | les documents | le texte et ce qu'il porte déjà (phrases, s'il y en a plus d'une), puis **ce qui a changé** : texte, phrases, annotations, catégories (du document ou par phrase, avec leur score et leur libellé), métadonnées, textes alternatifs — **et ce qui a été retiré** |
|
|
119
|
+
| segmenteur | les documents | les documents | le texte, puis les phrases numérotées, avec leurs métadonnées |
|
|
120
120
|
| convertisseur | l'`UploadFile` | les documents | le fichier (extrait), puis chaque document produit : identifiant, titre, propriétés, texte et phrases numérotées (les phrases vides comprises) |
|
|
121
|
-
| formateur | le document | la `Response` | le document, puis le type, le nom de fichier et un extrait de la réponse —
|
|
122
|
-
| importeur | le chemin du fichier | **la liste** des concepts | le fichier (extrait,
|
|
121
|
+
| formateur | le document | la `Response` | le document (annotations, catégories, textes alternatifs), puis le type, le nom de fichier et un extrait de la réponse — JSON réindenté, toutes les feuilles d'un classeur Excel, la taille pour un autre contenu binaire |
|
|
122
|
+
| importeur | le chemin du fichier | **la liste** des concepts | le fichier (extrait, feuilles d'un classeur Excel, fichiers d'une archive zip), puis les termes : identifiant, forme préférée, formes alternatives, autres propriétés |
|
|
123
123
|
|
|
124
124
|
Trois pièges, que la fixture signale ou ne peut pas rattraper :
|
|
125
125
|
|
|
@@ -137,12 +137,28 @@ Les textes, fichiers et réponses longs sont tronqués au rendu (1 000 caractèr
|
|
|
137
137
|
plutôt que compter sur la troncature.
|
|
138
138
|
|
|
139
139
|
Un texte dont la mise en page compte (une ligne indentée, un simple retour à la
|
|
140
|
-
ligne, des espaces alignés
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
ligne, des espaces alignés, des lignes vides en tête) ou qui contient des balises
|
|
141
|
+
(`<personne>`, `&`) est montré dans un bloc de code plutôt qu'en citation, qui
|
|
142
|
+
l'écraserait ou que Sherpa interpréterait comme du HTML ; dans un tableau, `<` est
|
|
143
|
+
échappé. Dans une liste de phrases, « ↵ » marque un retour à la ligne intérieur à la
|
|
144
|
+
phrase, « ↵↵ » une ligne vide. Un même texte trouvé à plusieurs endroits est numéroté
|
|
145
|
+
(« Paris *(n°2)* ») ; deux annotations du même passage ne le sont pas. Un fichier XML
|
|
146
|
+
ou HTML est montré sur 60 lignes, un autre texte sur 20. Un fichier qui n'est pas en UTF-8 est lu en Latin-1 et le rendu le
|
|
147
|
+
dit. Un classeur Excel est montré par ses feuilles, pas par sa taille : la
|
|
143
148
|
taille d'un `.xlsx` change avec la version d'openpyxl et ferait échouer
|
|
144
149
|
`py:help-check`. Il faut openpyxl dans l'environnement de test, ce qui est déjà le
|
|
145
|
-
cas d'un plugin qui lit ou écrit de l'Excel.
|
|
150
|
+
cas d'un plugin qui lit ou écrit de l'Excel. Une archive zip est montrée par la liste
|
|
151
|
+
de ses fichiers et le texte des premiers ; un document Office (docx, pptx), qui est
|
|
152
|
+
aussi une archive, n'est montré que par sa taille.
|
|
153
|
+
|
|
154
|
+
Un paramètre interne (la langue du projet, fixée par Sherpa) n'apparaît pas dans la
|
|
155
|
+
ligne des paramètres d'un exemple. Dans le tableau des paramètres, une valeur par défaut
|
|
156
|
+
est écrite sur une ligne (une chaîne JSON en JSON compact) et tronquée au-delà de
|
|
157
|
+
100 caractères : la prose en dit le reste si besoin.
|
|
158
|
+
|
|
159
|
+
**Jenkins vérifie les pages avec la tête de python-archetype** (le submodule est
|
|
160
|
+
récupéré avec `--remote`), pas avec le commit pointé : après un changement du rendu,
|
|
161
|
+
régénérer les pages des dépôts qu'il touche, sinon leur `py:help-check` passe au rouge.
|
|
146
162
|
|
|
147
163
|
Un exemple dans une langue (un texte français dans la page anglaise) se garde tel
|
|
148
164
|
quel — la langue du document est indiquée par le rendu (« Text (French) ») : le
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/build_rerank_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_linking.py
RENAMED
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_mappings.py
RENAMED
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_rerank.py
RENAMED
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/finetune_rerank.py
RENAMED
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/merge_rerank.py
RENAMED
|
File without changes
|
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.html
RENAMED
|
File without changes
|
{pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|