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.
Files changed (47) hide show
  1. pyannotators_entityfishing-1.6.103/LICENSE +17 -0
  2. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/PKG-INFO +8 -3
  3. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/README.md +4 -0
  4. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/pyproject.toml +2 -2
  5. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/__init__.py +1 -1
  6. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/.claude-plugin/plugin.json +1 -1
  7. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/help_examples.py +49 -19
  8. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/plugin-help.py +255 -57
  9. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/plugin-help/SKILL.md +25 -9
  10. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.claude/skills/ef-mapping/SKILL.md +0 -0
  11. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.gitignore +0 -0
  12. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.gitmodules +0 -0
  13. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/.python-version +0 -0
  14. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/Taskfile.yml +0 -0
  15. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/candidates.py +0 -0
  16. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/ef_client.py +0 -0
  17. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/src/pyannotators_entityfishing/entityfishing.py +0 -0
  18. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
  19. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/HOWTO.md +0 -0
  20. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/README.md +0 -0
  21. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/Stages.yml +0 -0
  22. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/Taskfile.yml +0 -0
  23. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/template.en.md +0 -0
  24. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/resources/help/template.fr.md +0 -0
  25. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -0
  26. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/annotator.md +0 -0
  27. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/converter.md +0 -0
  28. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/formatter.md +0 -0
  29. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/importer.md +0 -0
  30. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/processor.md +0 -0
  31. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/segmenter.md +0 -0
  32. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/skills/new-plugin/references/types.md +0 -0
  33. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/tools/check-marketplace.py +0 -0
  34. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
  35. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/build_rerank_data.py +0 -0
  36. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/check_rerank_endpoint.py +0 -0
  37. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/el_jev.py +0 -0
  38. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_linking.py +0 -0
  39. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_mappings.py +0 -0
  40. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/eval_rerank.py +0 -0
  41. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/finetune_rerank.py +0 -0
  42. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/merge_rerank.py +0 -0
  43. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/recalibrate_linking.py +0 -0
  44. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.html +0 -0
  45. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/review_linking.py +0 -0
  46. {pyannotators_entityfishing-1.6.99 → pyannotators_entityfishing-1.6.103}/tools/sherpa_plan_project.py +0 -0
  47. {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.99
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: MIT
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 :: OSI Approved :: MIT 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](https://img.shields.io/badge/license-proprietary-red)](LICENSE)
53
+ [![version](https://img.shields.io/pypi/v/pyannotators-entityfishing)](https://pypi.org/project/pyannotators-entityfishing/)
54
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyannotators-entityfishing)](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](https://img.shields.io/badge/license-proprietary-red)](LICENSE)
4
+ [![version](https://img.shields.io/pypi/v/pyannotators-entityfishing)](https://pypi.org/project/pyannotators-entityfishing/)
5
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyannotators-entityfishing)](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 = "MIT"}
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 :: OSI Approved :: MIT License",
26
+ "License :: Other/Proprietary License",
27
27
  "Development Status :: 4 - Beta",
28
28
  "Programming Language :: Python :: 3.12",
29
29
  ]
@@ -1,3 +1,3 @@
1
1
  """Annotator based on entity-fishing"""
2
2
 
3
- __version__ = "1.6.99"
3
+ __version__ = "1.6.103"
@@ -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.4",
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 sa première feuille : au-delà, la page n'en citerait qu'un extrait.
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 _sheet(data: bytes) -> dict | None:
45
- """La première feuille d'un classeur Excel, ou None si ce n'en est pas un (ou sans openpyxl)."""
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
- sheet = workbook.worksheets[0]
55
- rows = []
56
- for row in sheet.iter_rows(values_only=True):
57
- if len(rows) == MAX_SHEET_ROWS:
58
- break
59
- rows.append(["" if value is None else str(value) for value in row])
60
- # Sans les colonnes vides de droite, qu'openpyxl rend quand une ligne est plus longue.
61
- width = max((max((i + 1 for i, v in enumerate(row) if v), default=0) for row in rows), default=0)
62
- return {"name": sheet.title, "sheets": len(workbook.worksheets), "rows": [row[:width] for row in rows]}
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, la première feuille d'un classeur, ou sa taille."""
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
- sheet = _sheet(data)
85
- if sheet is not None:
86
- content["sheet"] = sheet
87
- return content
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 f"`{json.dumps(value, ensure_ascii=False)}`"
291
- return f"`{value}`"
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
- default = format_default(prop, lang)
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
- for name in sorted(params, key=lambda n: list(properties).index(n) if n in properties else len(properties)):
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
- def cell(text: str) -> str:
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("&", "&amp;").replace("<", "&lt;"))
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
- parts = code_block(shown, "text", lang, max_line=None) if LAYOUT.search(text.strip("\n")) else [quote(shown)]
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
- lines = text.strip("\n").splitlines()
413
- shown = [line if max_line is None or len(line) <= max_line else line[:max_line] + " …" for line in lines[:MAX_LINES]]
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) > MAX_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, la première feuille d'un classeur, ou sa taille."""
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 "sheet" in content:
559
+ if "sheets" in content:
452
560
  # La taille d'un classeur dépend de la version d'openpyxl : on montre ses cellules.
453
- sheet = content["sheet"]
454
- many = sheet.get("sheets", 1) > 1
455
- lead = texts["first_sheet" if many else "sheet"].format(name=cell(sheet.get("name", "")), n=sheet.get("sheets", 1))
456
- rows = [[cell(value) or " " for value in row] for row in sheet.get("rows", [])]
457
- if not rows:
458
- return [lead + texts["colon"], texts["empty"]]
459
- width = max(len(row) for row in rows)
460
- rows = [row + [" "] * (width - len(row)) for row in rows]
461
- # Les colonnes nommées comme dans Excel : rien ne dit si la première ligne est un en-tête.
462
- return [lead + texts["colon"], table([column_name(i) for i in range(width)], rows, lang)]
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 annoté plusieurs fois est numéroté, dans l'ordre du document.
474
- counts: dict[str, int] = {}
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
- counts[a.get("text", "")] = counts.get(a.get("text", ""), 0) + 1
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 counts[text] > 1:
486
- shown += f" *({texts['occurrence'].format(n=seen[text])})*"
487
- row = [shown, f"`{cell(a.get('labelName') or a.get('label') or '')}`"]
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
- # Trié : l'ordre des termes n'est pas toujours stable d'un passage à l'autre.
490
- forms = sorted({t.get("preferredForm") or t.get("identifier", "") for t in a.get("terms", [])})
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é (&lt;) 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
- header = [texts["label"]] + ([texts["score"]] if with_score else [])
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"`{cell(c.get('labelName') or c.get('label') or '')}`"]
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"`{cell(key)}`", cell(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False))] for key, value in metadata.items()]
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 = MARKDOWN_INLINE.sub(r"\\\1", cell(text[sent["start"] : sent["end"]]))
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
- parts = ["\n".join(f"{n}. {sentence_text(text, s, lang)}" for n, s in enumerate(sentences[:MAX_ROWS], start=1))]
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"`{cell(c.get('labelName') or c.get('label') or '')}`"
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']} `{cell(doc['identifier'])}`"
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"{key}_out"], table_of()]
802
+ parts += [texts[f"{name}_out"], table_of()]
610
803
  else:
611
- parts.append(texts[f"{key}_removed"].format(n=len(before[key])))
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"`{cell(key)}`" for key in removed) + ".")
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"`{cell(c.get('identifier', ''))}`", cell(c.get("preferredForm") or "") or "—"]
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 déjà, puis **ce qui a changé** : texte, phrases, annotations, catégories (du document ou par phrase, avec leur score), métadonnées — **et ce qui a été retiré** |
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 — la première feuille pour un classeur Excel, la taille pour un autre contenu binaire |
122
- | importeur | le chemin du fichier | **la liste** des concepts | le fichier (extrait, ou la première feuille d'un classeur Excel), puis les termes : identifiant, forme préférée, formes alternatives |
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) est montré dans un bloc de code plutôt qu'en citation,
141
- qui l'écraserait. Un fichier qui n'est pas en UTF-8 est lu en Latin-1 et le rendu le
142
- dit. Un classeur Excel est montré par sa première feuille, pas par sa taille : la
140
+ ligne, des espaces alignés, des lignes vides en tête) ou qui contient des balises
141
+ (`<personne>`, `&amp;`) 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