@piecemaker-legal/piecemaker 1.0.6 → 1.0.7

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 (209) hide show
  1. package/dist/assets/{abnfDiagram-VCTEODGH-DF7LFtT-.js → abnfDiagram-VCTEODGH-CR3_ADBu.js} +1 -1
  2. package/dist/assets/{arc-xOmjWeVH.js → arc-b92imMmc.js} +1 -1
  3. package/dist/assets/{architectureDiagram-5GKGNRK7-CESzNPwa.js → architectureDiagram-5GKGNRK7-Dfm5Epud.js} +1 -1
  4. package/dist/assets/{blockDiagram-I7D4REHJ-BjdN78Ad.js → blockDiagram-I7D4REHJ-B8kLLyAv.js} +1 -1
  5. package/dist/assets/{c4Diagram-7LVT6UL2-BcK9EDWT.js → c4Diagram-7LVT6UL2-BWSyAAWX.js} +1 -1
  6. package/dist/assets/channel-BbcW_Tkh.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-DGUN1JFP.js → chunk-2Q5K7J3B-Ci743mcC.js} +1 -1
  8. package/dist/assets/{chunk-5VM5RSS4-BG3BBcFr.js → chunk-5VM5RSS4-SdXpal6h.js} +1 -1
  9. package/dist/assets/{chunk-F27PBJKO-CLIOfrF5.js → chunk-F27PBJKO-Rof94ueX.js} +1 -1
  10. package/dist/assets/{chunk-IMKFNOWR-D6lGkqij.js → chunk-IMKFNOWR-B3st6ECc.js} +1 -1
  11. package/dist/assets/{chunk-JWPE2WC7-BmF9vLBT.js → chunk-JWPE2WC7-alXmu5xU.js} +1 -1
  12. package/dist/assets/{chunk-POPQ4Y6H-C_0SR3ak.js → chunk-POPQ4Y6H-dMs1OCce.js} +1 -1
  13. package/dist/assets/{chunk-SVP7TREG-rT20Wfpp.js → chunk-SVP7TREG-BmM8FsmF.js} +1 -1
  14. package/dist/assets/{chunk-TICWLB2K-Cn2hkmcf.js → chunk-TICWLB2K-CL1LytYG.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-Dm_eP1_v.js → chunk-XXDRQBXY-7U9iRPT1.js} +1 -1
  16. package/dist/assets/classDiagram-ZZMXUADV-D9_YRZ7i.js +1 -0
  17. package/dist/assets/classDiagram-v2-VYDZK3BY-D9_YRZ7i.js +1 -0
  18. package/dist/assets/{cose-bilkent-JH36ORCC-cJeeJsep.js → cose-bilkent-JH36ORCC-DFOMlhj2.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-DX2ePIXe.js → cynefin-OW5HDTMX-CDdJFd9d.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-5FMLGOSQ-BvWXN-RR.js → cynefinDiagram-5FMLGOSQ-DPyVNrMW.js} +1 -1
  21. package/dist/assets/{dagre-GXQ25YYZ-CkwPRhGR.js → dagre-GXQ25YYZ-B3bpHa9D.js} +1 -1
  22. package/dist/assets/{diagram-S7CK7UJ4-CBGNzYyv.js → diagram-S7CK7UJ4-DeFO2rXB.js} +1 -1
  23. package/dist/assets/{diagram-UQ7AKVKN-CiwCU0SJ.js → diagram-UQ7AKVKN-CbdGTOVf.js} +1 -1
  24. package/dist/assets/{diagram-VSXAHHWV-Aekg4nTk.js → diagram-VSXAHHWV-DFfNQyMd.js} +1 -1
  25. package/dist/assets/{diagram-VX7I27RA-DPFP5K7r.js → diagram-VX7I27RA-DbNZ6_X3.js} +1 -1
  26. package/dist/assets/{diagram-Z3DM3KII-BHJkxoey.js → diagram-Z3DM3KII-B8F-ZkNJ.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-PWID7BFC-2rV1P32T.js → ebnfDiagram-PWID7BFC-BfCCPPAP.js} +1 -1
  28. package/dist/assets/{erDiagram-RLTQ6QDP-C4BjyS_H.js → erDiagram-RLTQ6QDP-DcV-3Kzv.js} +1 -1
  29. package/dist/assets/{flowDiagram-HODETNUW-DL1TBlAG.js → flowDiagram-HODETNUW-BwSkJXCo.js} +1 -1
  30. package/dist/assets/{ganttDiagram-EL5Y4UJY-MKjvzCWz.js → ganttDiagram-EL5Y4UJY-BffHk77T.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-WWUBYQGX-CS_oTGK5.js → gitGraphDiagram-WWUBYQGX-CQ1EWa74.js} +1 -1
  32. package/dist/assets/{index-XOGQDqU3.js → index-BqK33gaZ.js} +120 -120
  33. package/dist/assets/index-D_Dc_nQv.css +1 -0
  34. package/dist/assets/{infoDiagram-27XIBGKW-ihUr2GjN.js → infoDiagram-27XIBGKW-DqIaBXQn.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-5VMMS53U-D2YAnuir.js → ishikawaDiagram-5VMMS53U-HlhsaZgv.js} +1 -1
  36. package/dist/assets/{journeyDiagram-3NMN7TZE-Bc2hSZGD.js → journeyDiagram-3NMN7TZE-BYO8-7rN.js} +1 -1
  37. package/dist/assets/{kanban-definition-UXKFOSKX-LRMGNonX.js → kanban-definition-UXKFOSKX-DXb8zHGT.js} +1 -1
  38. package/dist/assets/{layout-CcfWOlPV.js → layout-CREOVuEZ.js} +1 -1
  39. package/dist/assets/{linear-7JoS2gQO.js → linear-CVCrIRIY.js} +1 -1
  40. package/dist/assets/{mermaid.core-ijh_SsuN.js → mermaid.core-DFqeWtQR.js} +6 -6
  41. package/dist/assets/{mindmap-definition-YA3MSWOX-DCE4nzfF.js → mindmap-definition-YA3MSWOX-SUpXz333.js} +1 -1
  42. package/dist/assets/{pegDiagram-XKGWAZYB-SjaGa7lE.js → pegDiagram-XKGWAZYB-D-yWpve3.js} +1 -1
  43. package/dist/assets/{pieDiagram-E7YTZNPT-1xcRidSZ.js → pieDiagram-E7YTZNPT-BJX4Zp4Z.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-AXDQQJYC-obHAHnYV.js → quadrantDiagram-AXDQQJYC-CHajheTu.js} +1 -1
  45. package/dist/assets/{railroadDiagram-O6MQD6OU-BsCVcRZb.js → railroadDiagram-O6MQD6OU-DHJWzW5A.js} +1 -1
  46. package/dist/assets/{requirementDiagram-BXWQKSXE-CUNkhvqb.js → requirementDiagram-BXWQKSXE-DigUIdso.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-P5KCCOFB-DhezXMKm.js → sankeyDiagram-P5KCCOFB-CFUDDbA5.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-WJ2MYXX4-CL9VswXH.js → sequenceDiagram-WJ2MYXX4-6tSaXfYD.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-t0g9jiEz.js → sizeCapture-INFHLROL-CXASEDKe.js} +1 -1
  50. package/dist/assets/{stateDiagram-D77RDMKH-BW-g-IOz.js → stateDiagram-D77RDMKH-DWTCAQfZ.js} +1 -1
  51. package/dist/assets/stateDiagram-v2-MP3YSRHH-HDQF4MGA.js +1 -0
  52. package/dist/assets/{swimlanes-42K2YHIH-LsuyeBte.js → swimlanes-42K2YHIH-7-oz5r-e.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-VR7AAH4N-CJbgQFt8.js +8 -0
  54. package/dist/assets/{timeline-definition-24CTP7MA-B0cFka1R.js → timeline-definition-24CTP7MA-DVH5g4ty.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-C5qqu4R7.js → vendor-codemirror-CjW0lHR5.js} +5 -5
  56. package/dist/assets/{vennDiagram-4TSXK5OY-D3HNCDR3.js → vennDiagram-4TSXK5OY-CQvY_ALZ.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VM6X3IG4-DJ3Co8nb.js → wardleyDiagram-VM6X3IG4-BdlTJgEc.js} +1 -1
  58. package/dist/assets/{xychartDiagram-S5SC5T6Z-Cz5E9New.js → xychartDiagram-S5SC5T6Z-CYn2sV77.js} +1 -1
  59. package/dist/favicon.png +0 -0
  60. package/dist/favicon.svg +1 -9
  61. package/dist/generate-icons.js +47 -46
  62. package/dist/icons/icon-128x128.png +0 -0
  63. package/dist/icons/icon-128x128.svg +1 -12
  64. package/dist/icons/icon-144x144.png +0 -0
  65. package/dist/icons/icon-144x144.svg +1 -12
  66. package/dist/icons/icon-152x152.png +0 -0
  67. package/dist/icons/icon-152x152.svg +1 -12
  68. package/dist/icons/icon-192x192.png +0 -0
  69. package/dist/icons/icon-192x192.svg +1 -12
  70. package/dist/icons/icon-384x384.png +0 -0
  71. package/dist/icons/icon-384x384.svg +1 -12
  72. package/dist/icons/icon-512x512.png +0 -0
  73. package/dist/icons/icon-512x512.svg +1 -12
  74. package/dist/icons/icon-72x72.png +0 -0
  75. package/dist/icons/icon-72x72.svg +1 -12
  76. package/dist/icons/icon-96x96.png +0 -0
  77. package/dist/icons/icon-96x96.svg +1 -12
  78. package/dist/index.html +3 -3
  79. package/dist/logo-128.png +0 -0
  80. package/dist/logo-256.png +0 -0
  81. package/dist/logo-32.png +0 -0
  82. package/dist/logo-512.png +0 -0
  83. package/dist/logo-64.png +0 -0
  84. package/dist/logo-sources/logo-black.png +0 -0
  85. package/dist/logo-sources/logo-white.png +0 -0
  86. package/dist/logo.svg +1 -17
  87. package/dist/sw.js +1 -1
  88. package/dist-server/plugins/piecemaker-dossier/src/institutional-terms.js +91 -0
  89. package/dist-server/plugins/piecemaker-dossier/src/institutional-terms.js.map +1 -0
  90. package/dist-server/plugins/piecemaker-dossier/src/knowledge.js +112 -10
  91. package/dist-server/plugins/piecemaker-dossier/src/knowledge.js.map +1 -1
  92. package/dist-server/plugins/piecemaker-dossier/src/scan-result.js +5 -1
  93. package/dist-server/plugins/piecemaker-dossier/src/scan-result.js.map +1 -1
  94. package/dist-server/server/index.js +60 -26
  95. package/dist-server/server/index.js.map +1 -1
  96. package/dist-server/server/modules/database/repositories/projects.db.js +28 -6
  97. package/dist-server/server/modules/database/repositories/projects.db.js.map +1 -1
  98. package/dist-server/server/modules/database/tests/projects.db.integration.test.js +58 -1
  99. package/dist-server/server/modules/database/tests/projects.db.integration.test.js.map +1 -1
  100. package/dist-server/server/modules/plugins/plugins.routes.js +8 -1
  101. package/dist-server/server/modules/plugins/plugins.routes.js.map +1 -1
  102. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +2 -0
  103. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  104. package/dist-server/server/piecemaker/bodacc-search.js +191 -0
  105. package/dist-server/server/piecemaker/bodacc-search.js.map +1 -0
  106. package/dist-server/server/piecemaker/company-search.js +145 -0
  107. package/dist-server/server/piecemaker/company-search.js.map +1 -0
  108. package/dist-server/server/piecemaker/company-search.test.js +18 -0
  109. package/dist-server/server/piecemaker/company-search.test.js.map +1 -0
  110. package/dist-server/server/piecemaker/index.js +6 -2
  111. package/dist-server/server/piecemaker/index.js.map +1 -1
  112. package/dist-server/server/piecemaker/knowledge/index.js +9 -4
  113. package/dist-server/server/piecemaker/knowledge/index.js.map +1 -1
  114. package/dist-server/server/piecemaker/knowledge/pipeline.js +68 -58
  115. package/dist-server/server/piecemaker/knowledge/pipeline.js.map +1 -1
  116. package/dist-server/server/piecemaker/knowledge/routes.js +2 -4
  117. package/dist-server/server/piecemaker/knowledge/routes.js.map +1 -1
  118. package/dist-server/server/piecemaker/knowledge/scan-jobs.js +91 -0
  119. package/dist-server/server/piecemaker/knowledge/scan-jobs.js.map +1 -0
  120. package/dist-server/server/piecemaker/knowledge/service.js +18 -36
  121. package/dist-server/server/piecemaker/knowledge/service.js.map +1 -1
  122. package/dist-server/server/piecemaker/library/connector-installation.js +224 -0
  123. package/dist-server/server/piecemaker/library/connector-installation.js.map +1 -0
  124. package/dist-server/server/piecemaker/library/index.js +11 -1
  125. package/dist-server/server/piecemaker/library/index.js.map +1 -1
  126. package/dist-server/server/piecemaker/library/marketplace.js +102 -43
  127. package/dist-server/server/piecemaker/library/marketplace.js.map +1 -1
  128. package/dist-server/server/piecemaker/library/migrate.js +8 -1
  129. package/dist-server/server/piecemaker/library/migrate.js.map +1 -1
  130. package/dist-server/server/piecemaker/library/provider-agents.js +50 -0
  131. package/dist-server/server/piecemaker/library/provider-agents.js.map +1 -0
  132. package/dist-server/server/piecemaker/library/provider-connectors.js +80 -0
  133. package/dist-server/server/piecemaker/library/provider-connectors.js.map +1 -0
  134. package/dist-server/server/piecemaker/library/provider-skills.js +11 -2
  135. package/dist-server/server/piecemaker/library/provider-skills.js.map +1 -1
  136. package/dist-server/server/piecemaker/library/store.js +125 -35
  137. package/dist-server/server/piecemaker/library/store.js.map +1 -1
  138. package/dist-server/server/piecemaker/library/workspace-installation.js +53 -29
  139. package/dist-server/server/piecemaker/library/workspace-installation.js.map +1 -1
  140. package/dist-server/server/piecemaker/timesheet/service.js +4 -21
  141. package/dist-server/server/piecemaker/timesheet/service.js.map +1 -1
  142. package/package.json +3 -2
  143. package/scripts/piecemaker/cli/install-command.mjs +35 -9
  144. package/scripts/piecemaker/cli/lib/config.mjs +1 -0
  145. package/scripts/piecemaker/cli/lib/piecemaker-anonymizer.mjs +78 -0
  146. package/scripts/piecemaker/cli/lib/plugins.mjs +2 -0
  147. package/scripts/piecemaker/cli/lib/pwa.mjs +46 -7
  148. package/scripts/piecemaker/cli/lib/services.mjs +65 -4
  149. package/scripts/piecemaker/cli/piecemaker-command.test.mjs +25 -0
  150. package/scripts/piecemaker/cli/piecemaker.mjs +45 -44
  151. package/scripts/piecemaker/cli/piecemaker.ps1 +8 -0
  152. package/scripts/piecemaker/cli/piecemaker.sh +6 -1
  153. package/server/index.ts +52 -23
  154. package/server/modules/database/repositories/projects.db.ts +28 -6
  155. package/server/modules/database/tests/projects.db.integration.test.ts +63 -1
  156. package/server/modules/plugins/plugins.routes.ts +8 -1
  157. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +5 -0
  158. package/server/piecemaker/anonymizer/client-config.cjs +10 -2
  159. package/server/piecemaker/anonymizer/dictionary.cjs +6 -72
  160. package/server/piecemaker/bodacc-search.ts +205 -0
  161. package/server/piecemaker/company-search.test.ts +26 -0
  162. package/server/piecemaker/company-search.ts +183 -0
  163. package/server/piecemaker/index.ts +8 -2
  164. package/server/piecemaker/knowledge/index.ts +9 -4
  165. package/server/piecemaker/knowledge/pipeline.ts +89 -57
  166. package/server/piecemaker/knowledge/routes.ts +2 -4
  167. package/server/piecemaker/knowledge/scan-jobs.ts +111 -0
  168. package/server/piecemaker/knowledge/service.ts +18 -33
  169. package/server/piecemaker/library/connector-installation.ts +248 -0
  170. package/server/piecemaker/library/index.ts +5 -1
  171. package/server/piecemaker/library/marketplace.ts +104 -39
  172. package/server/piecemaker/library/migrate.ts +8 -1
  173. package/server/piecemaker/library/provider-agents.ts +49 -0
  174. package/server/piecemaker/library/provider-connectors.ts +89 -0
  175. package/server/piecemaker/library/provider-skills.ts +10 -1
  176. package/server/piecemaker/library/store.ts +129 -36
  177. package/server/piecemaker/library/workspace-installation.ts +54 -29
  178. package/server/piecemaker/router.cjs +2 -1
  179. package/server/piecemaker/timesheet/service.ts +4 -16
  180. package/server/piecemaker/vendor/installer/steps/03-python-gliner.mjs +0 -27
  181. package/server/piecemaker/vendor/installer/steps/14-mxc-sandbox.mjs +1 -1
  182. package/server/piecemaker/vendor/piecemaker-plugin/scripts/lib/substitution.cjs +2 -3
  183. package/server/piecemaker/vendor/piecemaker-plugin/skills/conversion-md/SKILL.md +3 -3
  184. package/server/piecemaker/vendor/websocket-server/admin-routes.cjs +3 -10
  185. package/server/piecemaker/vendor/websocket-server/originals-pipeline.cjs +135 -4
  186. package/server/piecemaker/vendor/websocket-server/process-group.cjs +95 -0
  187. package/server/piecemaker/vendor/websocket-server/scripts/__pycache__/scan_utils.cpython-312.pyc +0 -0
  188. package/server/piecemaker/vendor/websocket-server/scripts/convert_and_scan_pipeline.py +280 -149
  189. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/__pycache__/model_config.cpython-312.pyc +0 -0
  190. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/__pycache__/scanner_worker.cpython-312.pyc +0 -0
  191. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/scanner_worker.py +35 -29
  192. package/server/piecemaker/vendor/websocket-server/scripts/requirements.txt +0 -5
  193. package/server/shared/types.ts +1 -0
  194. package/dist/assets/channel-Dz3eE5vU.js +0 -1
  195. package/dist/assets/classDiagram-ZZMXUADV-BC1eKV2j.js +0 -1
  196. package/dist/assets/classDiagram-v2-VYDZK3BY-BC1eKV2j.js +0 -1
  197. package/dist/assets/index-DhwJpHCq.css +0 -1
  198. package/dist/assets/stateDiagram-v2-MP3YSRHH-BXuT4zDG.js +0 -1
  199. package/dist/assets/swimlanesDiagram-VR7AAH4N-B0eYrB4U.js +0 -8
  200. package/server/piecemaker/vendor/websocket-server/case-instructions.cjs +0 -111
  201. package/server/piecemaker/vendor/websocket-server/scripts/ADDRESS_ANONYMIZATION.md +0 -216
  202. package/server/piecemaker/vendor/websocket-server/scripts/DEDUPLICATION_CHANGES.md +0 -134
  203. package/server/piecemaker/vendor/websocket-server/scripts/GLINER2_README.md +0 -138
  204. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/AUDIT_PERF_QUALITE.md +0 -246
  205. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/MODELE_COREML.md +0 -62
  206. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/__pycache__/coreml_runtime.cpython-312.pyc +0 -0
  207. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/build_coreml.py +0 -118
  208. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/coreml_runtime.py +0 -164
  209. package/server/piecemaker/vendor/websocket-server/scripts/presidio-gliner/presidio-gliner.py +0 -479
@@ -3,7 +3,7 @@
3
3
  Convert & Scan Pipeline — orchestrates document conversion and PII scanning.
4
4
 
5
5
  This script chains smart_converter.py (conversion to Markdown) and
6
- presidio-gliner.py (PII scanning) for batch processing of multiple documents.
6
+ scanner_worker.py (PII scanning) for batch processing of multiple documents.
7
7
 
8
8
  Usage:
9
9
  python3 convert_and_scan_pipeline.py <file1> [file2 ...] -o <output_dir> [options]
@@ -48,6 +48,10 @@ from pathlib import Path
48
48
  from typing import List, Optional, Tuple, Dict, Set
49
49
 
50
50
 
51
+ GLINER_CHUNK_SIZE = 384
52
+ GLINER_CHUNK_OVERLAP = 64
53
+
54
+
51
55
  def print_progress(phase: str, current: int, total: int) -> None:
52
56
  """Print standardized progress message.
53
57
 
@@ -93,7 +97,11 @@ def start_scanner_worker() -> Optional[subprocess.Popen]:
93
97
  return None
94
98
 
95
99
 
96
- def wait_for_worker_ready(proc: Optional[subprocess.Popen], timeout: int = 500) -> bool:
100
+ def wait_for_worker_ready(
101
+ proc: Optional[subprocess.Popen],
102
+ progress_context: Dict,
103
+ timeout: int = 500,
104
+ ) -> bool:
97
105
  """Wait for the scanner worker to print READY on stdout.
98
106
 
99
107
  While waiting, streams stderr output (model loading progress) in real-time.
@@ -108,9 +116,9 @@ def wait_for_worker_ready(proc: Optional[subprocess.Popen], timeout: int = 500)
108
116
  if proc is None:
109
117
  return False
110
118
 
111
- import re
112
119
  import select
113
120
  import threading
121
+ progress_context["event"] = threading.Event()
114
122
 
115
123
  _chunk_re = re.compile(r"^PROGRESS:CHUNKS:\d+:\d+:\d+")
116
124
 
@@ -126,9 +134,24 @@ def wait_for_worker_ready(proc: Optional[subprocess.Popen], timeout: int = 500)
126
134
  for line in proc.stderr:
127
135
  stripped = line.strip()
128
136
  if _chunk_re.match(stripped):
129
- print(stripped, flush=True)
137
+ _, _, _, current, raw_total = stripped.split(":")
138
+ global_total = progress_context.get("total", 0)
139
+ if global_total:
140
+ global_current = min(global_total, progress_context.get("offset", 0) + int(current))
141
+ global_percent = int(global_current * 100 / global_total)
142
+ print(
143
+ f"PROGRESS:CHUNKS:{global_percent}:{global_current}:{global_total}",
144
+ flush=True,
145
+ )
146
+ else:
147
+ print(stripped, flush=True)
148
+ if int(current) >= int(raw_total):
149
+ progress_context["event"].set()
130
150
  else:
131
- print(f" [worker] {line}", end="", flush=True)
151
+ # Keep diagnostics on stderr. The Node parent deliberately
152
+ # ignores non-PROGRESS stdout because it may contain document
153
+ # text, while it retains a bounded stderr tail on failure.
154
+ print(f" [worker] {line}", end="", file=sys.stderr, flush=True)
132
155
  except (ValueError, OSError):
133
156
  pass # Pipe closed
134
157
 
@@ -141,11 +164,12 @@ def wait_for_worker_ready(proc: Optional[subprocess.Popen], timeout: int = 500)
141
164
  print(f"⚠️ Scanner worker exited prematurely (exit {proc.returncode})", file=sys.stderr)
142
165
  return False
143
166
 
144
- # Non-blocking read from stdout
167
+ # TextIOWrapper does not reliably distinguish EAGAIN from EOF when its
168
+ # descriptor is toggled to non-blocking mode. select() keeps the stream
169
+ # blocking and reads only after a complete line is available.
145
170
  try:
146
- os.set_blocking(proc.stdout.fileno(), False)
147
- line = proc.stdout.readline()
148
- os.set_blocking(proc.stdout.fileno(), True)
171
+ readable, _, _ = select.select([proc.stdout], [], [], 0.1)
172
+ line = proc.stdout.readline() if readable else ""
149
173
  except (IOError, OSError):
150
174
  line = ""
151
175
 
@@ -153,16 +177,23 @@ def wait_for_worker_ready(proc: Optional[subprocess.Popen], timeout: int = 500)
153
177
  print("✅ Scanner worker ready (models loaded)")
154
178
  return True
155
179
 
156
- if not line:
157
- time.sleep(0.1)
158
-
159
180
  # Timeout
160
181
  print("⚠️ Scanner worker timed out waiting for READY", file=sys.stderr)
161
182
  proc.kill()
183
+ try:
184
+ proc.wait(timeout=5)
185
+ except subprocess.TimeoutExpired:
186
+ pass
162
187
  return False
163
188
 
164
189
 
165
- def scan_file_via_worker(proc: subprocess.Popen, md_file: str, output_dir: str) -> bool:
190
+ def scan_file_via_worker(
191
+ proc: subprocess.Popen,
192
+ md_file: str,
193
+ output_dir: str,
194
+ progress_context: Optional[Dict] = None,
195
+ chunk_offset: int = 0,
196
+ ) -> bool:
166
197
  """Send a scan command to the worker and wait for the result.
167
198
 
168
199
  Args:
@@ -173,6 +204,9 @@ def scan_file_via_worker(proc: subprocess.Popen, md_file: str, output_dir: str)
173
204
  Returns:
174
205
  True if scan succeeded, False otherwise.
175
206
  """
207
+ if progress_context is not None:
208
+ progress_context["offset"] = chunk_offset
209
+ progress_context["event"].clear()
176
210
  cmd = json.dumps({"cmd": "scan", "md_file": md_file, "output_dir": output_dir})
177
211
  try:
178
212
  proc.stdin.write(cmd + "\n")
@@ -198,6 +232,9 @@ def scan_file_via_worker(proc: subprocess.Popen, md_file: str, output_dir: str)
198
232
  print(f"⚠️ Invalid JSON from worker: {response_line.strip()}", file=sys.stderr)
199
233
  return False
200
234
 
235
+ if progress_context is not None and progress_context.get("active_chunks", 0):
236
+ progress_context["event"].wait(timeout=5)
237
+
201
238
  if response.get("status") == "ok":
202
239
  return True
203
240
  else:
@@ -207,15 +244,43 @@ def scan_file_via_worker(proc: subprocess.Popen, md_file: str, output_dir: str)
207
244
 
208
245
  def stop_scanner_worker(proc: Optional[subprocess.Popen]) -> None:
209
246
  """Gracefully stop the scanner worker."""
210
- if proc is None or proc.poll() is not None:
247
+ if proc is None:
211
248
  return
212
249
 
213
250
  try:
214
- proc.stdin.write(json.dumps({"cmd": "quit"}) + "\n")
215
- proc.stdin.flush()
216
- proc.wait(timeout=10)
251
+ if proc.poll() is None:
252
+ proc.stdin.write(json.dumps({"cmd": "quit"}) + "\n")
253
+ proc.stdin.flush()
254
+ proc.wait(timeout=10)
217
255
  except Exception:
218
- proc.kill()
256
+ try:
257
+ proc.kill()
258
+ proc.wait(timeout=5)
259
+ except Exception:
260
+ pass
261
+ finally:
262
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
263
+ try:
264
+ stream.close()
265
+ except (AttributeError, OSError):
266
+ pass
267
+
268
+
269
+ class PipelineResources:
270
+ """Owns every heavyweight or sensitive transient pipeline resource."""
271
+
272
+ def __init__(self) -> None:
273
+ self.scanner_worker: Optional[subprocess.Popen] = None
274
+ self.scan_workspace: Optional[tempfile.TemporaryDirectory] = None
275
+
276
+ def close(self) -> None:
277
+ # The temporary maps contain raw PII and must be removed even when
278
+ # worker shutdown itself encounters a broken pipe or a dead process.
279
+ try:
280
+ stop_scanner_worker(self.scanner_worker)
281
+ finally:
282
+ if self.scan_workspace is not None:
283
+ self.scan_workspace.cleanup()
219
284
 
220
285
 
221
286
  def convert_file(
@@ -328,100 +393,6 @@ def convert_file(
328
393
  return False, None
329
394
 
330
395
 
331
- def scan_file(md_file: str, output_dir: str) -> bool:
332
- """Run presidio-gliner.py subprocess to scan Markdown for PII with real-time output streaming.
333
-
334
- Args:
335
- md_file: Path to markdown file
336
- output_dir: Directory for output JSON
337
-
338
- Returns:
339
- success: True if scan succeeded
340
- """
341
- script_dir = Path(__file__).parent
342
- scanner_script = script_dir / "presidio-gliner" / "presidio-gliner.py"
343
-
344
- if not scanner_script.exists():
345
- print(f"❌ presidio-gliner.py not found at {scanner_script}", file=sys.stderr)
346
- return False
347
-
348
- # Build command
349
- cmd = [sys.executable, str(scanner_script), md_file, "-o", output_dir]
350
-
351
- try:
352
- # Run scanner with real-time output streaming
353
- process = subprocess.Popen(
354
- cmd,
355
- stdout=subprocess.PIPE,
356
- stderr=subprocess.PIPE,
357
- text=True,
358
- bufsize=1, # Line buffered
359
- )
360
-
361
- # Stream output in real-time
362
- os.set_blocking(process.stdout.fileno(), False)
363
- os.set_blocking(process.stderr.fileno(), False)
364
-
365
- return_code = None
366
- while return_code is None:
367
- # Check if process has finished
368
- return_code = process.poll()
369
-
370
- # Read available output without blocking
371
- try:
372
- # Read stdout
373
- while True:
374
- line = process.stdout.readline()
375
- if not line:
376
- break
377
- print(line, end="", flush=True)
378
-
379
- # Read stderr
380
- while True:
381
- line = process.stderr.readline()
382
- if not line:
383
- break
384
- print(line, end="", file=sys.stderr, flush=True)
385
-
386
- except (IOError, OSError):
387
- pass
388
-
389
- # Small delay to prevent CPU spinning
390
- if return_code is None:
391
- time.sleep(0.01)
392
-
393
- # Ensure we read any remaining output
394
- try:
395
- remaining_stdout, remaining_stderr = process.communicate(timeout=1)
396
- if remaining_stdout:
397
- print(remaining_stdout, end="", flush=True)
398
- if remaining_stderr:
399
- print(remaining_stderr, end="", file=sys.stderr, flush=True)
400
- except subprocess.TimeoutExpired:
401
- pass
402
-
403
- if return_code != 0:
404
- print(f"⚠️ Scan failed with exit code {return_code}", file=sys.stderr)
405
- return False
406
-
407
- # Verify JSON output was created
408
- md_stem = Path(md_file).stem
409
- json_path = Path(output_dir) / f"{md_stem}_sensitive_map.json"
410
-
411
- if not json_path.exists():
412
- print(f"⚠️ Expected JSON output not found: {json_path}", file=sys.stderr)
413
- return False
414
-
415
- return True
416
-
417
- except subprocess.TimeoutExpired:
418
- print(f"❌ Scan timeout (>10 minutes): {md_file}", file=sys.stderr)
419
- return False
420
- except Exception as e:
421
- print(f"❌ Scan error: {e}", file=sys.stderr)
422
- return False
423
-
424
-
425
396
  def load_existing_mapping(mapping_path: Path) -> Optional[Dict]:
426
397
  """Load the case/document mapping file if it exists.
427
398
 
@@ -568,6 +539,25 @@ def are_names_similar(name1: str, norm1: str, name2: str, norm2: str) -> bool:
568
539
  return False
569
540
 
570
541
 
542
+ def count_gliner_chunks(md_file: str) -> int:
543
+ try:
544
+ word_count = len(re.findall(r"\S+", Path(md_file).read_text(encoding="utf-8")))
545
+ except (OSError, UnicodeError):
546
+ return 1
547
+ if not word_count:
548
+ return 1
549
+ step = max(1, GLINER_CHUNK_SIZE - GLINER_CHUNK_OVERLAP)
550
+ return (max(0, word_count - GLINER_CHUNK_SIZE) + step - 1) // step + 1
551
+
552
+
553
+ def emit_chunk_progress(current: int, total: int) -> None:
554
+ if total <= 0:
555
+ return
556
+ bounded_current = min(total, max(0, current))
557
+ percent = int(bounded_current * 100 / total)
558
+ print(f"PROGRESS:CHUNKS:{percent}:{bounded_current}:{total}", flush=True)
559
+
560
+
571
561
  def consolidate_duplicate_entities(entities: List[Dict]) -> List[Dict]:
572
562
  """Consolidate duplicate entities into single entries with variants.
573
563
 
@@ -643,6 +633,84 @@ def consolidate_duplicate_entities(entities: List[Dict]) -> List[Dict]:
643
633
  return consolidated
644
634
 
645
635
 
636
+ def _person_entry_texts(code: str, entry: Dict, reverse_mapping: Dict,
637
+ mapping_texts: Optional[List[str]] = None) -> List[str]:
638
+ values = [entry.get("original", ""), *(entry.get("variants") or [])]
639
+ values.extend(reverse_mapping.get(code, []) or [])
640
+ values.extend(mapping_texts or [])
641
+ return list(dict.fromkeys(
642
+ value for value in values
643
+ if isinstance(value, str) and value.strip()
644
+ ))
645
+
646
+
647
+ def _merge_person_entry(merged_mapping: Dict, merged_reverse: Dict,
648
+ merged_extracted: Dict, code: str, texts: List[str],
649
+ source_entry: Optional[Dict] = None) -> None:
650
+ entries = merged_extracted.setdefault("personnes_physiques", {})
651
+ entry = entries.setdefault(code, {
652
+ "original": texts[0] if texts else "",
653
+ "code": code,
654
+ "variants": [],
655
+ })
656
+ current_texts = _person_entry_texts(code, entry, merged_reverse)
657
+ combined = list(dict.fromkeys([*current_texts, *texts]))
658
+ if combined:
659
+ entry["original"] = max(combined, key=len)
660
+ entry["variants"] = combined
661
+ merged_reverse[code] = list(dict.fromkeys([
662
+ *(merged_reverse.get(code, []) or []), *combined
663
+ ]))
664
+ for text in combined:
665
+ merged_mapping[text] = code
666
+ if source_entry:
667
+ entry["score"] = max(entry.get("score", 0.0), source_entry.get("score", 0.0))
668
+ entry.setdefault("recognizer", source_entry.get("recognizer", "unknown"))
669
+
670
+
671
+ def _prune_existing_person_duplicates(merged_mapping: Dict, merged_reverse: Dict,
672
+ merged_extracted: Dict) -> None:
673
+ entries = merged_extracted.setdefault("personnes_physiques", {})
674
+ mapping_texts_by_code: Dict[str, List[str]] = {}
675
+ for text, code in merged_mapping.items():
676
+ mapping_texts_by_code.setdefault(code, []).append(text)
677
+
678
+ canonical_by_name: Dict[str, str] = {}
679
+ for code in list(entries):
680
+ entry = entries.get(code, {})
681
+ texts = _person_entry_texts(
682
+ code,
683
+ entry,
684
+ merged_reverse,
685
+ mapping_texts_by_code.get(code),
686
+ )
687
+ names = [normalize_name(text) for text in texts]
688
+ names = list(dict.fromkeys(name for name in names if name))
689
+ survivor = next((canonical_by_name[name] for name in names if name in canonical_by_name), None)
690
+ if survivor is None:
691
+ for name in names:
692
+ canonical_by_name[name] = code
693
+ continue
694
+ if survivor == code:
695
+ continue
696
+
697
+ _merge_person_entry(
698
+ merged_mapping,
699
+ merged_reverse,
700
+ merged_extracted,
701
+ survivor,
702
+ texts,
703
+ entry,
704
+ )
705
+ for name in names:
706
+ canonical_by_name[name] = survivor
707
+ for text, mapped_code in list(merged_mapping.items()):
708
+ if mapped_code == code:
709
+ merged_mapping[text] = survivor
710
+ merged_reverse.pop(code, None)
711
+ entries.pop(code, None)
712
+
713
+
646
714
  def is_known_city_or_country(text: str) -> bool:
647
715
  """Check if text matches known city or country names.
648
716
 
@@ -1188,8 +1256,21 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1188
1256
  "autres": {**existing_mapping.get('extracted_data', {}).get('autres', {})}
1189
1257
  }
1190
1258
 
1191
- # Track seen entities to avoid duplicates
1259
+ _prune_existing_person_duplicates(merged_mapping, merged_reverse, merged_extracted)
1260
+
1192
1261
  seen_entities_lower = {k.lower(): v for k, v in merged_mapping.items()}
1262
+ existing_person_by_name: Dict[str, str] = {}
1263
+ for code, entry in merged_extracted["personnes_physiques"].items():
1264
+ for entity_text in _person_entry_texts(code, entry, merged_reverse):
1265
+ normalized = normalize_name(entity_text)
1266
+ if normalized:
1267
+ existing_person_by_name.setdefault(normalized, code)
1268
+ for entity_text, code in merged_mapping.items():
1269
+ if "PERSONNE_PHYSIQUE_" not in str(code) and not str(code).startswith(("DIRIGEANT_", "AVOCAT_")):
1270
+ continue
1271
+ normalized = normalize_name(entity_text)
1272
+ if normalized:
1273
+ existing_person_by_name.setdefault(normalized, code)
1193
1274
 
1194
1275
  # Find highest existing code numbers per category
1195
1276
  code_counters = {
@@ -1204,7 +1285,7 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1204
1285
  # URGOT SA), là où un test par sous-chaîne ne reconnaîtrait pas SA_3.
1205
1286
  societe_counters: Dict[str, int] = {}
1206
1287
 
1207
- for code in merged_reverse.keys():
1288
+ for code in set(merged_reverse) | set(merged_mapping.values()):
1208
1289
  if "PERSONNE_PHYSIQUE_" in code or code.startswith("DIRIGEANT_"):
1209
1290
  num = int(code.split("_")[-1])
1210
1291
  code_counters["personnes_physiques"] = max(code_counters["personnes_physiques"], num + 1)
@@ -1226,15 +1307,6 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1226
1307
  for text, code in new_mapping.get('mapping', {}).items():
1227
1308
  text_lower = text.lower()
1228
1309
 
1229
- # Skip if already exists (use existing code)
1230
- if text_lower in seen_entities_lower:
1231
- continue
1232
-
1233
- # Skip entities the lawyer discarded from the mapping
1234
- if text_lower in ignored_lower:
1235
- continue
1236
-
1237
- # Determine category
1238
1310
  category = None
1239
1311
  for cat_key in merged_extracted.keys():
1240
1312
  if code in new_mapping.get('extracted_data', {}).get(cat_key, {}):
@@ -1244,6 +1316,42 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1244
1316
  if not category:
1245
1317
  category = "autres"
1246
1318
 
1319
+ old_entry = new_mapping.get('extracted_data', {}).get(category, {}).get(code, {})
1320
+ variants = old_entry.get("variants", [text])
1321
+ candidate_texts = list(dict.fromkeys([text, *variants]))
1322
+
1323
+ if category == "personnes_physiques":
1324
+ existing_code = next(
1325
+ (
1326
+ existing_person_by_name[normalized]
1327
+ for candidate in candidate_texts
1328
+ for normalized in [normalize_name(candidate)]
1329
+ if normalized in existing_person_by_name
1330
+ ),
1331
+ None,
1332
+ )
1333
+ if existing_code:
1334
+ accepted_texts = [candidate for candidate in candidate_texts if candidate.lower() not in ignored_lower]
1335
+ _merge_person_entry(
1336
+ merged_mapping,
1337
+ merged_reverse,
1338
+ merged_extracted,
1339
+ existing_code,
1340
+ accepted_texts,
1341
+ old_entry,
1342
+ )
1343
+ for candidate in accepted_texts:
1344
+ seen_entities_lower[candidate.lower()] = existing_code
1345
+ continue
1346
+
1347
+ # Skip if already exists (use existing code)
1348
+ if text_lower in seen_entities_lower:
1349
+ continue
1350
+
1351
+ # Skip entities the lawyer discarded from the mapping
1352
+ if text_lower in ignored_lower:
1353
+ continue
1354
+
1247
1355
  # Generate new code with incremented counter
1248
1356
  if category == "personnes_physiques":
1249
1357
  new_code = f"PERSONNE_PHYSIQUE_{code_counters[category]:02d}"
@@ -1267,13 +1375,14 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1267
1375
  new_code = f"{entity_type}_{code_counters[category]:02d}"
1268
1376
  code_counters[category] += 1
1269
1377
 
1270
- # Get variants from existing entry
1271
- old_entry = new_mapping.get('extracted_data', {}).get(category, {}).get(code, {})
1272
- variants = old_entry.get("variants", [text])
1273
-
1274
1378
  # Add all variants to merged structures (no lowercase duplicates)
1275
- for variant in variants:
1379
+ accepted_variants = [variant for variant in variants if variant.lower() not in ignored_lower]
1380
+ for variant in accepted_variants:
1276
1381
  merged_mapping[variant] = new_code
1382
+ seen_entities_lower[variant.lower()] = new_code
1383
+ normalized = normalize_name(variant) if category == "personnes_physiques" else ""
1384
+ if normalized:
1385
+ existing_person_by_name.setdefault(normalized, new_code)
1277
1386
 
1278
1387
  seen_entities_lower[text_lower] = new_code
1279
1388
 
@@ -1285,7 +1394,7 @@ def merge_with_existing_mapping(new_mapping: Dict, existing_mapping: Optional[Di
1285
1394
  merged_extracted[category][new_code] = {
1286
1395
  "original": original,
1287
1396
  "code": new_code,
1288
- "variants": variants,
1397
+ "variants": accepted_variants,
1289
1398
  "score": old_entry.get("score", 1.0),
1290
1399
  "recognizer": old_entry.get("recognizer", "unknown")
1291
1400
  }
@@ -1633,7 +1742,7 @@ def write_document_index(
1633
1742
  return written
1634
1743
 
1635
1744
 
1636
- def main():
1745
+ def run_pipeline(resources: PipelineResources):
1637
1746
  """Main pipeline orchestration."""
1638
1747
  parser = argparse.ArgumentParser(
1639
1748
  description="Convert documents to Markdown and scan for sensitive data",
@@ -1697,6 +1806,7 @@ def main():
1697
1806
  help="Reuse existing Markdown and scans whose source fingerprint is unchanged",
1698
1807
  )
1699
1808
  args = parser.parse_args()
1809
+ progress_context = {"offset": 0, "total": 0, "active_chunks": 0}
1700
1810
 
1701
1811
  # Validate input files
1702
1812
  input_files = []
@@ -1747,7 +1857,8 @@ def main():
1747
1857
 
1748
1858
  # Start scanner worker immediately so model loading overlaps with Phase 1.
1749
1859
  if scan_needed:
1750
- scanner_worker = start_scanner_worker()
1860
+ resources.scanner_worker = start_scanner_worker()
1861
+ scanner_worker = resources.scanner_worker
1751
1862
  else:
1752
1863
  scanner_worker = None
1753
1864
  print("✅ Every PII scan is already up to date — GLiNER not started")
@@ -1825,19 +1936,27 @@ def main():
1825
1936
  if skipped_scans:
1826
1937
  print(f"⏭️ {skipped_scans} file(s) already scanned, left untouched")
1827
1938
 
1828
- # Wait for the scanner worker to finish loading models. One retry (clean
1829
- # up the stalled/dead worker, relaunch, wait again) before falling back
1830
- # to a subprocess-per-file scan.
1831
- worker_ready = wait_for_worker_ready(scanner_worker) if pending_scans else False
1939
+ chunk_counts = {md_file: count_gliner_chunks(md_file) for md_file in pending_scans}
1940
+ total_chunks = sum(chunk_counts.values())
1941
+ progress_context["total"] = total_chunks
1942
+
1943
+ # Wait for the scanner worker to finish loading models. One retry cleans up
1944
+ # and relaunches a stalled/dead worker; a second failure aborts the job so
1945
+ # the heavyweight model is never reloaded once per document.
1946
+ worker_ready = wait_for_worker_ready(scanner_worker, progress_context) if pending_scans else False
1832
1947
 
1833
1948
  if pending_scans and not worker_ready:
1834
1949
  print("⚠️ Scanner worker not ready — cleaning up and retrying once", file=sys.stderr)
1835
1950
  stop_scanner_worker(scanner_worker)
1836
- scanner_worker = start_scanner_worker()
1837
- worker_ready = wait_for_worker_ready(scanner_worker)
1951
+ resources.scanner_worker = start_scanner_worker()
1952
+ scanner_worker = resources.scanner_worker
1953
+ worker_ready = wait_for_worker_ready(scanner_worker, progress_context)
1838
1954
 
1839
1955
  if pending_scans and not worker_ready:
1840
- print("⚠️ Scanner worker not available after retry, falling back to subprocess-per-file", file=sys.stderr)
1956
+ raise RuntimeError(
1957
+ "Scanner GLiNER indisponible après deux tentatives ; "
1958
+ "le traitement est arrêté pour éviter de recharger le modèle pour chaque fichier."
1959
+ )
1841
1960
  print()
1842
1961
 
1843
1962
  scan_success_count = 0
@@ -1846,18 +1965,26 @@ def main():
1846
1965
  index_path = state_target.parent / "document-index.json"
1847
1966
  # Raw detections contain PII. They live only in a private OS temporary
1848
1967
  # directory and are deleted individually as each file is merged.
1849
- scan_workspace = tempfile.TemporaryDirectory(prefix="piecemaker-scans-")
1968
+ resources.scan_workspace = tempfile.TemporaryDirectory(prefix="piecemaker-scans-")
1969
+ scan_workspace = resources.scan_workspace
1850
1970
  scan_output_dir = scan_workspace.name
1851
1971
 
1972
+ emit_chunk_progress(0, total_chunks)
1973
+ completed_chunks = 0
1852
1974
  for i, md_file in enumerate(pending_scans, start=1):
1853
- print_progress("SCAN", i, len(pending_scans))
1854
1975
  print(f"🔍 [{i}/{len(pending_scans)}] Scanning: {Path(md_file).name}")
1855
1976
 
1856
- if worker_ready:
1857
- success = scan_file_via_worker(scanner_worker, md_file, scan_output_dir)
1858
- else:
1859
- # Fallback: subprocess per file (old behavior)
1860
- success = scan_file(md_file, scan_output_dir)
1977
+ progress_context["active_chunks"] = chunk_counts[md_file]
1978
+ success = scan_file_via_worker(
1979
+ scanner_worker,
1980
+ md_file,
1981
+ scan_output_dir,
1982
+ progress_context,
1983
+ completed_chunks,
1984
+ )
1985
+ if success:
1986
+ completed_chunks += chunk_counts[md_file]
1987
+ emit_chunk_progress(completed_chunks, total_chunks)
1861
1988
 
1862
1989
  if not success:
1863
1990
  print(f" ⚠️ Scan failed, markdown preserved")
@@ -1906,10 +2033,6 @@ def main():
1906
2033
  scan_success_count += 1
1907
2034
  print()
1908
2035
 
1909
- # Shut down worker
1910
- stop_scanner_worker(scanner_worker)
1911
- scan_workspace.cleanup()
1912
-
1913
2036
  print(f"✅ Phase 2 complete: {scan_success_count}/{len(pending_scans)} files scanned")
1914
2037
  print()
1915
2038
 
@@ -1954,6 +2077,14 @@ def main():
1954
2077
  return 1
1955
2078
 
1956
2079
 
2080
+ def main():
2081
+ resources = PipelineResources()
2082
+ try:
2083
+ return run_pipeline(resources)
2084
+ finally:
2085
+ resources.close()
2086
+
2087
+
1957
2088
  if __name__ == "__main__":
1958
2089
  try:
1959
2090
  sys.exit(main())