paratext-cli 0.2.4__tar.gz → 0.4.0__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 (187) hide show
  1. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-4 genericise-the-defaults-away-from-local-inference-servers.md +1 -2
  2. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/AGENTS.md +24 -20
  3. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/PKG-INFO +1 -1
  4. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/pyproject.toml +1 -1
  5. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/__init__.py +1 -1
  6. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/cli.py +25 -0
  7. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/packaging.py +8 -3
  8. paratext_cli-0.4.0/src/paratext/review/runs.py +171 -0
  9. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/server.py +191 -4
  10. paratext_cli-0.4.0/src/paratext/review/sessions.py +125 -0
  11. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/static/app.js +212 -131
  12. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/static/index.html +27 -0
  13. paratext_cli-0.4.0/src/paratext/workshop.py +105 -0
  14. paratext_cli-0.4.0/tests/test_runs.py +75 -0
  15. paratext_cli-0.4.0/tests/test_sessions.py +126 -0
  16. paratext_cli-0.4.0/tests/test_workshop.py +99 -0
  17. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/uv.lock +1 -1
  18. paratext_cli-0.2.4/backlog/config.yml +0 -16
  19. paratext_cli-0.2.4/backlog/milestones/m-0 - framework-split-and-first-release.md +0 -8
  20. paratext_cli-0.2.4/backlog/milestones/m-1 - review-ui.md +0 -8
  21. paratext_cli-0.2.4/backlog/milestones/m-2 - export-and-publishing.md +0 -8
  22. paratext_cli-0.2.4/backlog/milestones/m-3 - oat-first-ui-rework.md +0 -8
  23. paratext_cli-0.2.4/backlog/milestones/m-4 - packaging-and-pypi-release.md +0 -8
  24. paratext_cli-0.2.4/backlog/milestones/m-5 - marc-fidelity.md +0 -8
  25. paratext_cli-0.2.4/backlog/milestones/m-6 - test-coverage-and-onboarding.md +0 -8
  26. paratext_cli-0.2.4/backlog/milestones/m-7 - export-v2.md +0 -8
  27. paratext_cli-0.2.4/backlog/milestones/m-8 - review-ui-polish.md +0 -8
  28. paratext_cli-0.2.4/backlog/tasks/task-1 - Split-the-framework-out-of-paratext-nls-and-release-it.md +0 -27
  29. paratext_cli-0.2.4/backlog/tasks/task-10 - Remove-the-WattTime-provider.md +0 -26
  30. paratext_cli-0.2.4/backlog/tasks/task-11 - Make-paratext-new-self-installing.md +0 -26
  31. paratext_cli-0.2.4/backlog/tasks/task-12 - Add-paratext-guide-so-agents-can-self-orient.md +0 -27
  32. paratext_cli-0.2.4/backlog/tasks/task-13 - Guard-schema-prompt-and-view-drift.md +0 -27
  33. paratext_cli-0.2.4/backlog/tasks/task-14 - Build-the-human-corrected-gold-eval-set-in-review.md +0 -27
  34. paratext_cli-0.2.4/backlog/tasks/task-15 - Add-MARC-and-Dublin-Core-export-formats.md +0 -27
  35. paratext_cli-0.2.4/backlog/tasks/task-16 - Ship-schema.json-with-each-exported-dataset.md +0 -26
  36. paratext_cli-0.2.4/backlog/tasks/task-17 - Add-the-project-inspector-and-fix-sqlite-thread-safety.md +0 -27
  37. paratext_cli-0.2.4/backlog/tasks/task-18 - Point-the-default-card-detector-at-the-published-NLS-repo.md +0 -26
  38. paratext_cli-0.2.4/backlog/tasks/task-19 - Add-the-scope-aware-export-modal.md +0 -28
  39. paratext_cli-0.2.4/backlog/tasks/task-2 - Add-the-inbuilt-review-web-UI.md +0 -27
  40. paratext_cli-0.2.4/backlog/tasks/task-20 - Move-the-review-UI-onto-Oat.md +0 -27
  41. paratext_cli-0.2.4/backlog/tasks/task-21 - Render-entries-as-tables-read-only-and-editable.md +0 -26
  42. paratext_cli-0.2.4/backlog/tasks/task-22 - Publish-gold-to-Hugging-Face-with-Sign-in-with-HF.md +0 -27
  43. paratext_cli-0.2.4/backlog/tasks/task-23 - Warn-on-incomplete-provenance-without-blocking-publication.md +0 -26
  44. paratext_cli-0.2.4/backlog/tasks/task-24 - Add-the-paratext-logo-and-favicon.md +0 -26
  45. paratext_cli-0.2.4/backlog/tasks/task-25 - Remove-duplicated-helpers-across-the-codebase.md +0 -27
  46. paratext_cli-0.2.4/backlog/tasks/task-26 - Split-Store-and-dataset-loading-out-of-the-review-server.md +0 -27
  47. paratext_cli-0.2.4/backlog/tasks/task-27 - Lean-the-README-and-split-the-reference-into-docs.md +0 -27
  48. paratext_cli-0.2.4/backlog/tasks/task-28 - Add-NOTICE-and-make-the-repository-public.md +0 -27
  49. paratext_cli-0.2.4/backlog/tasks/task-29 - Rename-the-distribution-to-paratext-cli-and-publish-to-PyPI.md +0 -27
  50. paratext_cli-0.2.4/backlog/tasks/task-3 - Make-the-review-server-configurable.md +0 -26
  51. paratext_cli-0.2.4/backlog/tasks/task-30 - Make-project-discovery-work-from-any-environment.md +0 -27
  52. paratext_cli-0.2.4/backlog/tasks/task-31 - Give-clear-errors-for-provider-and-source-misconfiguration.md +0 -27
  53. paratext_cli-0.2.4/backlog/tasks/task-32 - Add-a-configurable-output-budget-and-provider-extra-body-passthrough.md +0 -27
  54. paratext_cli-0.2.4/backlog/tasks/task-33 - Bring-MARC-output-up-to-ISBD-punctuation-and-leader-conventions.md +0 -28
  55. paratext_cli-0.2.4/backlog/tasks/task-34 - Replace-the-blind-margin-crop-with-a-content-aware-fallback.md +0 -27
  56. paratext_cli-0.2.4/backlog/tasks/task-35 - Add-the-opt-in-AI-assistance-note-to-MARC-and-DC-export.md +0 -28
  57. paratext_cli-0.2.4/backlog/tasks/task-36 - Emit-only-one-main-entry-per-MARC-record.md +0 -28
  58. paratext_cli-0.2.4/backlog/tasks/task-37 - Extend-CLI-test-coverage-to-argparse-wiring-round-resolution-and-config-layering.md +0 -30
  59. paratext_cli-0.2.4/backlog/tasks/task-38 - Walk-a-real-run-from-a-fresh-scaffold.md +0 -28
  60. paratext_cli-0.2.4/backlog/tasks/task-39 - Add-the-paratext-dependency-when-scaffolding-into-an-existing-project.md +0 -27
  61. paratext_cli-0.2.4/backlog/tasks/task-4 - Genericise-the-defaults-away-from-local-inference-servers.md +0 -26
  62. paratext_cli-0.2.4/backlog/tasks/task-40 - Fix-cross-batch-accumulation-overwriting-metadata.jsonl.md +0 -26
  63. paratext_cli-0.2.4/backlog/tasks/task-41 - Remove-the-silent-hf-default-from-format.md +0 -27
  64. paratext_cli-0.2.4/backlog/tasks/task-42 - Add-paratext-export-db.md +0 -26
  65. paratext_cli-0.2.4/backlog/tasks/task-43 - Decide-whether-a-gold-export-ships-as-a-held-out-test-split.md +0 -26
  66. paratext_cli-0.2.4/backlog/tasks/task-44 - HF-export-v2-multi-image-projects-and-round-versioning-as-Hub-revisions.md +0 -26
  67. paratext_cli-0.2.4/backlog/tasks/task-45 - Add-paratext-bench.md +0 -26
  68. paratext_cli-0.2.4/backlog/tasks/task-46 - Review-UI-polish-flagged-stat-key-score-badge-and-verdict-toggles.md +0 -27
  69. paratext_cli-0.2.4/backlog/tasks/task-47 - Eval-editor-reach-View-hidden-fields-and-diff-corrected-against-model-output.md +0 -27
  70. paratext_cli-0.2.4/backlog/tasks/task-48 - Add-richer-scaffolder-field-types-for-enums-and-lists.md +0 -26
  71. paratext_cli-0.2.4/backlog/tasks/task-49 - Extend-the-carbon-providers.md +0 -25
  72. paratext_cli-0.2.4/backlog/tasks/task-5 - Add-source-adapters-and-slim-the-project-layout.md +0 -27
  73. paratext_cli-0.2.4/backlog/tasks/task-50 - Build-the-wordmark-in-a-mono-typeface.md +0 -24
  74. paratext_cli-0.2.4/backlog/tasks/task-51 - Decide-whether-to-pursue-the-PyPI-paratext-name.md +0 -26
  75. paratext_cli-0.2.4/backlog/tasks/task-6 - Rename-init-to-new-and-document-every-CLI-flag.md +0 -26
  76. paratext_cli-0.2.4/backlog/tasks/task-7 - Key-runs-and-packages-on-the-prompt-hash.md +0 -27
  77. paratext_cli-0.2.4/backlog/tasks/task-8 - Add-paratext-export-for-publishing-a-reviewed-round.md +0 -27
  78. paratext_cli-0.2.4/backlog/tasks/task-9 - Add-carbon-aware-scheduling.md +0 -27
  79. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  80. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  81. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.gitignore +0 -0
  82. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/intro.md +0 -0
  83. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-1 split-the-framework-out-of-paratext-nls-and-release-it.md +0 -0
  84. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-10 remove-the-watttime-provider.md +0 -0
  85. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-11 make-paratext-new-self-installing.md +0 -0
  86. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-12 add-paratext-guide-so-agents-can-self-orient.md +0 -0
  87. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-13 guard-schema-prompt-and-view-drift.md +0 -0
  88. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-14 build-the-human-corrected-gold-eval-set-in-review.md +0 -0
  89. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-15 add-marc-and-dublin-core-export-formats.md +0 -0
  90. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-16 ship-schema-json-with-each-exported-dataset.md +0 -0
  91. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-17 add-the-project-inspector-and-fix-sqlite-thread-safety.md +0 -0
  92. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-18 point-the-default-card-detector-at-the-published-nls-repo.md +0 -0
  93. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-19 add-the-scope-aware-export-modal.md +0 -0
  94. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-2 add-the-inbuilt-review-web-ui.md +0 -0
  95. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-20 move-the-review-ui-onto-oat.md +0 -0
  96. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-21 render-entries-as-tables-read-only-and-editable.md +0 -0
  97. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-22 publish-gold-to-hugging-face-with-sign-in-with-hf.md +0 -0
  98. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-23 warn-on-incomplete-provenance-without-blocking-publication.md +0 -0
  99. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-24 add-the-paratext-logo-and-favicon.md +0 -0
  100. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-25 remove-duplicated-helpers-across-the-codebase.md +0 -0
  101. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-26 split-store-and-dataset-loading-out-of-the-review-server.md +0 -0
  102. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-27 lean-the-readme-and-split-the-reference-into-docs.md +0 -0
  103. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-28 add-notice-and-make-the-repository-public.md +0 -0
  104. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-29 rename-the-distribution-to-paratext-cli-and-publish-to-pypi.md +0 -0
  105. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-3 make-the-review-server-configurable.md +0 -0
  106. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-30 make-project-discovery-work-from-any-environment.md +0 -0
  107. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-31 give-clear-errors-for-provider-and-source-misconfiguration.md +0 -0
  108. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-32 add-a-configurable-output-budget-and-provider-extra-body-pas.md +0 -0
  109. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-33 bring-marc-output-up-to-isbd-punctuation-and-leader-conventi.md +0 -0
  110. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-34 replace-the-blind-margin-crop-with-a-content-aware-fallback.md +0 -0
  111. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-35 add-the-opt-in-ai-assistance-note-to-marc-and-dc-export.md +0 -0
  112. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-36 emit-only-one-main-entry-per-marc-record.md +0 -0
  113. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-37 extend-cli-test-coverage-to-argparse-wiring-round-resolution.md +0 -0
  114. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-38 walk-a-real-run-from-a-fresh-scaffold.md +0 -0
  115. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-39 add-the-paratext-dependency-when-scaffolding-into-an-existin.md +0 -0
  116. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-40 fix-cross-batch-accumulation-overwriting-metadata-jsonl.md +0 -0
  117. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-41 remove-the-silent-hf-default-from-format.md +0 -0
  118. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-42 add-paratext-export-db.md +0 -0
  119. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-43 decide-whether-a-gold-export-ships-as-a-held-out-test-split.md +0 -0
  120. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-44 hf-export-v2-multi-image-projects-and-round-versioning-as-hu.md +0 -0
  121. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-45 add-paratext-bench.md +0 -0
  122. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-46 review-ui-polish-flagged-stat-key-score-badge-and-verdict-to.md +0 -0
  123. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-47 eval-editor-reach-view-hidden-fields-and-diff-corrected-agai.md +0 -0
  124. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-48 add-richer-scaffolder-field-types-for-enums-and-lists.md +0 -0
  125. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-49 extend-the-carbon-providers.md +0 -0
  126. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-5 add-source-adapters-and-slim-the-project-layout.md +0 -0
  127. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-50 build-the-wordmark-in-a-mono-typeface.md +0 -0
  128. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-51 decide-whether-to-pursue-the-pypi-paratext-name.md +0 -0
  129. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-6 rename-init-to-new-and-document-every-cli-flag.md +0 -0
  130. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-7 key-runs-and-packages-on-the-prompt-hash.md +0 -0
  131. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-8 add-paratext-export-for-publishing-a-reviewed-round.md +0 -0
  132. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/.helical/tasks/PTX-9 add-carbon-aware-scheduling.md +0 -0
  133. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/LICENSE +0 -0
  134. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/NOTICE +0 -0
  135. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/README.md +0 -0
  136. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/assets/logo.png +0 -0
  137. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/assets/logo.svg +0 -0
  138. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/configuration.md +0 -0
  139. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/export.md +0 -0
  140. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/green-scheduling.md +0 -0
  141. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/hf-export-spec.md +0 -0
  142. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/publishing.md +0 -0
  143. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/docs/scanned-cards.md +0 -0
  144. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/paratext.example.toml +0 -0
  145. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/AGENTS.md +0 -0
  146. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/carbon.py +0 -0
  147. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/cards.py +0 -0
  148. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/catalogue.py +0 -0
  149. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/config.py +0 -0
  150. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/datasets.py +0 -0
  151. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/extract.py +0 -0
  152. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/hf_export.py +0 -0
  153. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/inspect.py +0 -0
  154. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/io.py +0 -0
  155. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/paratext.example.toml +0 -0
  156. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/projects/__init__.py +0 -0
  157. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/projects/card_template/__init__.py +0 -0
  158. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/projects/card_template/prompt.md +0 -0
  159. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/projects/card_template/schema.py +0 -0
  160. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/records.py +0 -0
  161. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/__init__.py +0 -0
  162. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/hf_oauth.py +0 -0
  163. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/static/favicon.svg +0 -0
  164. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/static/oat.min.css +0 -0
  165. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/review/static/oat.min.js +0 -0
  166. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/runner.py +0 -0
  167. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/scaffold.py +0 -0
  168. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/sources.py +0 -0
  169. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/src/paratext/store.py +0 -0
  170. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_carbon.py +0 -0
  171. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_catalogue.py +0 -0
  172. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_cli.py +0 -0
  173. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_config.py +0 -0
  174. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_extract.py +0 -0
  175. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_guide.py +0 -0
  176. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_hf_export.py +0 -0
  177. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_io.py +0 -0
  178. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_packaging.py +0 -0
  179. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_project_audit.py +0 -0
  180. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_records.py +0 -0
  181. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_review.py +0 -0
  182. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_rounds.py +0 -0
  183. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_runner.py +0 -0
  184. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_scaffold.py +0 -0
  185. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_show_through.py +0 -0
  186. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_sources.py +0 -0
  187. {paratext_cli-0.2.4 → paratext_cli-0.4.0}/tests/test_verso.py +0 -0
@@ -4,11 +4,10 @@ title: Genericise the defaults away from local inference servers
4
4
  status: done
5
5
  horizon: now
6
6
  flow: clear
7
- priority: high
8
7
  labels:
9
8
  - docs
10
9
  created: '2026-06-28'
11
- updated: '2026-09-02'
10
+ updated: '2026-09-03'
12
11
  ---
13
12
 
14
13
  Defaults naming Lemonade and LM Studio make the framework look like it only runs against one person's setup.
@@ -267,35 +267,39 @@ alongside `_api_export_catalogue`.
267
267
  - **Bump `schema_version`** when a schema changes.
268
268
  - **Commits:** short messages, no Co-Authored-By trailer.
269
269
 
270
- ## Backlog tasks are public
270
+ ## Tasks in this repo are public
271
271
 
272
- This repository is public, and `backlog/` is committed with it. Task titles,
272
+ This repository is public, and `.helical/` is committed with it. Task titles,
273
273
  descriptions and notes are visible to anyone.
274
274
 
275
275
  Keep repo tasks to engineering work. Anything naming NLS internals, staff,
276
276
  suppliers, unannounced plans or licensing negotiations belongs in the
277
- `paratext-nls` backlog or the roadmap at `/opt/backlog` instead.
277
+ `paratext-nls` board or the `admin` project instead.
278
278
 
279
- <!-- BACKLOG.MD GUIDELINES START -->
280
- <!-- backlog.md-instructions-version: 1.50.1 -->
281
- <CRITICAL_INSTRUCTION>
279
+ <!-- HELICAL START -->
280
+ ## Tasks
282
281
 
283
- ## Backlog.md Workflow
282
+ This project's tasks live in `.helical/tasks/` and are managed with the `helical`
283
+ command. Read them before starting, and record what you find.
284
284
 
285
- This project uses Backlog.md for task and project management.
286
-
287
- **For every user request in this project, run `backlog instructions overview` before answering or taking action.**
288
-
289
- Use the overview to decide whether to search, read, create, or update Backlog tasks.
285
+ ```bash
286
+ helical ls -p <project> # open work here
287
+ helical show <ID> # one task in full
288
+ helical set <ID> --status doing # todo | doing | done
289
+ helical set <ID> --horizon next # now | next | future
290
+ helical set <ID> --flow waiting --blocker "why"
291
+ helical new "Title" -p <project> --horizon next
292
+ ```
290
293
 
291
- Before task lifecycle actions, read the matching detailed guide:
292
- - `backlog instructions task-creation` before creating or splitting tasks
293
- - `backlog instructions task-execution` before planning, changing status or assignee, adding a plan or implementation notes, or implementing task work
294
- - `backlog instructions task-finalization` before checking acceptance criteria, writing final summaries, or moving tasks to terminal statuses
294
+ `--json` works on every command. A task carries both a **status** (how far along)
295
+ and a **horizon** (when it matters); the routemap across all projects is built from
296
+ horizons, so set one deliberately.
295
297
 
296
- Use `backlog <command> --help` before running unfamiliar commands. Help shows options, fields, and examples.
298
+ **Flow** says who can unstick a task: `waiting` sits with someone else and needs
299
+ chasing, `blocked` sits with us and needs a decision. Neither is accepted without
300
+ `--blocker` explaining it.
297
301
 
298
- Do not edit Backlog task, draft, document, decision, or milestone markdown files directly. Use the `backlog` CLI so metadata, relationships, and history stay consistent.
302
+ Labels come from a fixed vocabulary; `helical new --help` lists it.
299
303
 
300
- </CRITICAL_INSTRUCTION>
301
- <!-- BACKLOG.MD GUIDELINES END -->
304
+ Boards: <https://helical.ai.nls.uk> · read-only <https://projects.ai.nls.uk>
305
+ <!-- HELICAL END -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paratext-cli
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: Modular, project-based metadata extraction from digitised library/archive collections with a multimodal model
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -3,7 +3,7 @@
3
3
  # the plain name on PyPI belongs to an unrelated project, so the distribution
4
4
  # carries the `-cli` suffix.
5
5
  name = "paratext-cli"
6
- version = "0.2.4"
6
+ version = "0.4.0"
7
7
  description = "Modular, project-based metadata extraction from digitised library/archive collections with a multimodal model"
8
8
  readme = "README.md"
9
9
  # Core is pure-Python + numpy/pillow/pydantic. The [detector] extra needs
@@ -1,3 +1,3 @@
1
1
  """paratext — metadata extraction from digitised collections with a multimodal model."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.4.0"
@@ -427,6 +427,22 @@ def _cmd_carbon(args: argparse.Namespace) -> int:
427
427
 
428
428
 
429
429
  # ── Review ────────────────────────────────────────────────────────────────
430
+ def _workshop_endpoint(args: argparse.Namespace) -> dict:
431
+ """Where workshop runs send their model calls. Comes from the same
432
+ config/env layer as `run`, so a Space configures it with PARATEXT_* vars."""
433
+ merged = {**HARDCODED_DEFAULTS, **coerce_paths(load_defaults(None))}
434
+ base_url, note = normalise_base_url(str(merged.get("base_url") or ""))
435
+ if note:
436
+ print(note)
437
+ source = args.workshop_source or merged.get("source")
438
+ return {
439
+ "base_url": base_url,
440
+ "api_key": merged.get("api_key"),
441
+ "model": merged.get("model"),
442
+ "source": str(source) if source else None,
443
+ }
444
+
445
+
430
446
  def _cmd_review(args: argparse.Namespace) -> int:
431
447
  from .review import serve
432
448
 
@@ -439,6 +455,8 @@ def _cmd_review(args: argparse.Namespace) -> int:
439
455
  # A missing default root means "nothing run yet", not a mistake — serve
440
456
  # anyway so Projects and the homepage guidance are reachable.
441
457
  allow_empty=args.data_dir == REVIEW_ROOT,
458
+ workshop=args.workshop,
459
+ endpoint=_workshop_endpoint(args) if args.workshop else None,
442
460
  )
443
461
  return 0
444
462
 
@@ -779,6 +797,13 @@ def _build_parser() -> tuple[
779
797
  "nginx; use 0.0.0.0 to expose directly on the network)")
780
798
  rv.add_argument("--db", type=Path, default=None,
781
799
  help="Annotations SQLite path (default: <data_dir>/annotations.db)")
800
+ rv.add_argument("--workshop", metavar="DIR", default=None,
801
+ help="Workshop mode: give each browser its own workspace under DIR "
802
+ "(prompt, fields, rounds and verdicts), seeded with the rounds "
803
+ "served here")
804
+ rv.add_argument("--workshop-source", metavar="DIR", default=None,
805
+ help="With --workshop: the images attendees' runs read from "
806
+ "(default: the configured `source`)")
782
807
  rv.add_argument("--no-open", action="store_true", help="Don't open a browser")
783
808
  rv.set_defaults(func=_cmd_review)
784
809
 
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
  from PIL import Image
26
26
 
27
27
  from .io import iter_records, read_provenance
28
- from .projects import KEEP, Curation, build_view, get_project
28
+ from .projects import KEEP, Curation, Project, build_view, get_project
29
29
 
30
30
  logger = logging.getLogger(__name__)
31
31
 
@@ -71,9 +71,14 @@ def package(
71
71
  *,
72
72
  fresh: bool = False,
73
73
  image_max_size: int = 1024,
74
+ proj: "Project | None" = None,
74
75
  ) -> tuple[int, dict[str, int]]:
75
- """Write samples.json + images/ for `paratext review`. Returns (kept, skipped_by_reason)."""
76
- proj = get_project(project)
76
+ """Write samples.json + images/ for `paratext review`. Returns (kept, skipped_by_reason).
77
+
78
+ `proj` overrides entry-point lookup, for a project built at runtime rather
79
+ than installed — workshop mode, where the schema is edited in the browser.
80
+ """
81
+ proj = proj or get_project(project)
77
82
  if fresh and out.exists():
78
83
  shutil.rmtree(out)
79
84
  (out / "images").mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,171 @@
1
+ """Background extraction runs for workshop mode.
2
+
3
+ `extract.run` is built for a sweep: resume, tqdm, a whole collection. A workshop
4
+ run is five cards that somebody is watching, so this is a small loop that
5
+ reports progress per card and can be polled.
6
+
7
+ One job per session at a time. A run costs money, so the caps here are the
8
+ thing standing between a public Space and someone's credit balance.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from openai import OpenAI
21
+
22
+ from ..extract import _prompt_hash
23
+ from ..io import append_jsonl, write_provenance_header
24
+ from ..packaging import package
25
+ from ..projects import Project
26
+ from ..runner import call_structured
27
+ from ..sources import image_source
28
+ from ..workshop import build_schema
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ MAX_CARDS = 5 # per run
33
+ MAX_RUNS_PER_SESSION = 40
34
+
35
+
36
+ @dataclass
37
+ class Job:
38
+ id: str
39
+ total: int
40
+ done: int = 0
41
+ status: str = "running" # running | done | error
42
+ error: str = ""
43
+ round_name: str = ""
44
+ failures: list[str] = field(default_factory=list)
45
+
46
+ def as_dict(self) -> dict:
47
+ return {
48
+ "id": self.id,
49
+ "total": self.total,
50
+ "done": self.done,
51
+ "status": self.status,
52
+ "error": self.error,
53
+ "round": self.round_name,
54
+ "failures": self.failures,
55
+ }
56
+
57
+
58
+ class Runs:
59
+ """Tracks one job per session, and how many runs a session has spent."""
60
+
61
+ def __init__(self, max_runs: int = MAX_RUNS_PER_SESSION):
62
+ self._jobs: dict[str, Job] = {}
63
+ self._by_session: dict[str, str] = {}
64
+ self._spent: dict[str, int] = {}
65
+ self._lock = threading.Lock()
66
+ self.max_runs = max_runs
67
+
68
+ def active(self, session_id: str) -> Job | None:
69
+ with self._lock:
70
+ job = self._jobs.get(self._by_session.get(session_id, ""))
71
+ return job if job and job.status == "running" else None
72
+
73
+ def get(self, job_id: str) -> Job | None:
74
+ with self._lock:
75
+ return self._jobs.get(job_id)
76
+
77
+ def spent(self, session_id: str) -> int:
78
+ with self._lock:
79
+ return self._spent.get(session_id, 0)
80
+
81
+ def start(self, session_id: str, total: int, work) -> Job:
82
+ """Register a job and run `work(job)` on a thread."""
83
+ with self._lock:
84
+ if self._spent.get(session_id, 0) >= self.max_runs:
85
+ raise ValueError(
86
+ f"This session has used its {self.max_runs} runs. "
87
+ f"Reload with a fresh session to continue."
88
+ )
89
+ self._spent[session_id] = self._spent.get(session_id, 0) + 1
90
+ job = Job(id=uuid.uuid4().hex[:12], total=total)
91
+ self._jobs[job.id] = job
92
+ self._by_session[session_id] = job.id
93
+
94
+ def target():
95
+ try:
96
+ work(job)
97
+ job.status = "done" if job.status == "running" else job.status
98
+ except Exception as e: # noqa: BLE001 — surfaced to the UI
99
+ logger.exception("workshop run failed")
100
+ job.status, job.error = "error", str(e)
101
+
102
+ threading.Thread(target=target, daemon=True).start()
103
+ return job
104
+
105
+
106
+ def workshop_project(name: str, prompt: str, fields: list[dict]) -> Project:
107
+ """A Project assembled from what the attendee typed, not from an installed
108
+ package."""
109
+ return Project(
110
+ name=name,
111
+ schema_version="v1",
112
+ prompt=prompt,
113
+ schema=build_schema(fields),
114
+ source=image_source(),
115
+ )
116
+
117
+
118
+ def extract_and_package(
119
+ job: Job,
120
+ *,
121
+ proj: Project,
122
+ source: Path,
123
+ output: Path,
124
+ review_out: Path,
125
+ base_url: str,
126
+ api_key: str,
127
+ model: str,
128
+ cards: int,
129
+ ) -> None:
130
+ """Run the model over `cards` samples, then package a round. Updates `job`
131
+ as it goes so the browser can draw a progress bar."""
132
+ client = OpenAI(base_url=base_url, api_key=api_key)
133
+ output.parent.mkdir(parents=True, exist_ok=True)
134
+ if output.exists():
135
+ output.unlink() # a workshop run is always a fresh round
136
+ write_provenance_header(output, {
137
+ "project": proj.name,
138
+ "schema_version": proj.schema_version,
139
+ "prompt_hash": _prompt_hash(proj.prompt),
140
+ "prompt": proj.prompt,
141
+ "model": model,
142
+ "base_url": base_url,
143
+ })
144
+
145
+ for sample in proj.source.iter_samples(source, cards):
146
+ t0 = time.monotonic()
147
+ try:
148
+ parsed = call_structured(
149
+ client, model=model, prompt=proj.prompt, images=sample.images,
150
+ schema=proj.schema, image_max_size=proj.image_max_size,
151
+ image_quality=proj.image_quality,
152
+ )
153
+ append_jsonl(output, {
154
+ "id": sample.id,
155
+ "extraction": parsed.model_dump(),
156
+ "metadata": sample.metadata,
157
+ "elapsed_s": round(time.monotonic() - t0, 3),
158
+ })
159
+ except Exception as e: # noqa: BLE001
160
+ job.failures.append(f"{sample.id}: {e}")
161
+ logger.warning("[%s] failed: %s", sample.id, e)
162
+ finally:
163
+ job.done += 1
164
+
165
+ if job.done and len(job.failures) == job.done:
166
+ raise RuntimeError(f"every card failed — {job.failures[0]}")
167
+
168
+ kept, _ = package(output, review_out, proj.name, fresh=True, proj=proj)
169
+ job.round_name = review_out.name
170
+ if not kept:
171
+ raise RuntimeError("nothing survived packaging")
@@ -18,6 +18,7 @@ import io
18
18
  import json
19
19
  import logging
20
20
  import webbrowser
21
+ from http.cookies import SimpleCookie
21
22
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
23
  from pathlib import Path
23
24
  from urllib.parse import parse_qs, unquote, urlparse
@@ -32,6 +33,9 @@ from ..datasets import (
32
33
  schema_history,
33
34
  )
34
35
  from ..store import Store, default_db_path
36
+ from ..workshop import normalise_fields
37
+ from .runs import MAX_CARDS, Runs, extract_and_package, workshop_project
38
+ from .sessions import COOKIE_NAME, Sessions
35
39
 
36
40
  logger = logging.getLogger(__name__)
37
41
 
@@ -67,18 +71,141 @@ _MIME = {
67
71
 
68
72
 
69
73
  class Handler(BaseHTTPRequestHandler):
70
- data_dir: Path
71
- store: Store
74
+ # The single-tenant defaults. In workshop mode `data_dir` and `store` below
75
+ # resolve per request instead, so one container serves a room of people
76
+ # without them overwriting each other.
77
+ base_data_dir: Path
78
+ base_store: Store
79
+ sessions: Sessions | None = None
80
+ runs: Runs = Runs()
81
+ workshop_defaults: dict = {}
72
82
 
73
83
  def log_message(self, *a): # quiet by default
74
84
  pass
75
85
 
86
+ # -- workshop mode --
87
+ def _workshop_or_404(self):
88
+ session = self._session()
89
+ if session is None:
90
+ raise FileNotFoundError("workshop mode is not enabled on this server")
91
+ return session
92
+
93
+ def _api_workshop_state(self):
94
+ """The attendee's prompt and fields, falling back to the defaults this
95
+ server was started with."""
96
+ session = self._workshop_or_404()
97
+ state = session.read_state()
98
+ self._json({
99
+ "session": session.id,
100
+ "prompt": state.get("prompt", type(self).workshop_defaults.get("prompt", "")),
101
+ "fields": state.get("fields", type(self).workshop_defaults.get("fields", [])),
102
+ "runs_used": self.runs.spent(session.id),
103
+ "max_runs": self.runs.max_runs,
104
+ "max_cards": MAX_CARDS,
105
+ })
106
+
107
+ def _api_workshop_save(self):
108
+ session = self._workshop_or_404()
109
+ body = self._body()
110
+ fields = normalise_fields(body.get("fields") or [])
111
+ if not fields:
112
+ return self._json({"error": "a schema needs at least one field"}, 400)
113
+ session.write_state({"prompt": body.get("prompt") or "", "fields": fields})
114
+ self._json({"ok": True, "fields": fields})
115
+
116
+ def _api_workshop_run(self):
117
+ session = self._workshop_or_404()
118
+ if self.runs.active(session.id):
119
+ return self._json({"error": "a run is already going"}, 409)
120
+
121
+ body = self._body()
122
+ prompt = (body.get("prompt") or "").strip()
123
+ fields = normalise_fields(body.get("fields") or [])
124
+ if not prompt:
125
+ return self._json({"error": "the prompt is empty"}, 400)
126
+ if not fields:
127
+ return self._json({"error": "a schema needs at least one field"}, 400)
128
+ cards = max(1, min(int(body.get("cards") or MAX_CARDS), MAX_CARDS))
129
+
130
+ cfg = type(self).workshop_defaults
131
+ if not cfg.get("source") or not Path(cfg["source"]).is_dir():
132
+ return self._json({"error": "this server has no source images configured"}, 400)
133
+
134
+ session.write_state({"prompt": prompt, "fields": fields})
135
+ name = cfg.get("project", "workshop")
136
+ proj = workshop_project(name, prompt, fields)
137
+ # A new round per prompt: the round number is just how many the attendee
138
+ # has made, so r1/r2/r3 read as their own iteration history.
139
+ existing = sorted(session.data_dir.glob(f"{name}-r*"))
140
+ n = len(existing) + 1
141
+ review_out = session.data_dir / f"{name}-r{n}"
142
+
143
+ try:
144
+ job = self.runs.start(
145
+ session.id, cards,
146
+ lambda j: extract_and_package(
147
+ j, proj=proj, source=Path(cfg["source"]),
148
+ output=session.dir / "output" / f"{name}-r{n}.jsonl",
149
+ review_out=review_out,
150
+ base_url=cfg["base_url"], api_key=cfg["api_key"],
151
+ model=cfg["model"], cards=cards,
152
+ ),
153
+ )
154
+ except ValueError as e:
155
+ return self._json({"error": str(e)}, 429)
156
+ self._json(job.as_dict(), 202)
157
+
158
+ def _api_workshop_job(self, job_id):
159
+ self._workshop_or_404()
160
+ job = self.runs.get(job_id)
161
+ if not job:
162
+ raise FileNotFoundError(f"no such job: {job_id}")
163
+ self._json(job.as_dict())
164
+
165
+ # -- per-request session --
166
+ def _session(self):
167
+ """The caller's workshop session, creating one on first request. None
168
+ outside workshop mode, where the server is single-tenant."""
169
+ if self.sessions is None:
170
+ return None
171
+ if getattr(self, "_session_cache", None) is None:
172
+ cookie = SimpleCookie(self.headers.get("cookie") or "")
173
+ morsel = cookie.get(COOKIE_NAME)
174
+ session, created = self.sessions.get_or_create(morsel.value if morsel else None)
175
+ self._session_cache = session
176
+ self._set_cookie = created
177
+ return self._session_cache
178
+
179
+ @property
180
+ def data_dir(self) -> Path:
181
+ session = self._session()
182
+ return session.data_dir if session else type(self).base_data_dir
183
+
184
+ @property
185
+ def store(self) -> Store:
186
+ session = self._session()
187
+ if not session:
188
+ return type(self).base_store
189
+ if getattr(self, "_store_cache", None) is None:
190
+ self._store_cache = session.store()
191
+ return self._store_cache
192
+
193
+ def _session_headers(self) -> None:
194
+ """Hand the browser its session on the response that created it."""
195
+ if getattr(self, "_set_cookie", False):
196
+ self.send_header(
197
+ "set-cookie",
198
+ f"{COOKIE_NAME}={self._session_cache.id}; Path=/; SameSite=Lax; Max-Age=86400",
199
+ )
200
+ self._set_cookie = False
201
+
76
202
  # -- helpers --
77
203
  def _json(self, obj, status=200):
78
204
  body = json.dumps(obj).encode()
79
205
  self.send_response(status)
80
206
  self.send_header("content-type", "application/json")
81
207
  self.send_header("content-length", str(len(body)))
208
+ self._session_headers()
82
209
  self.end_headers()
83
210
  self.wfile.write(body)
84
211
 
@@ -88,6 +215,7 @@ class Handler(BaseHTTPRequestHandler):
88
215
  self.send_header("content-length", str(len(body)))
89
216
  for k, v in (headers or {}).items():
90
217
  self.send_header(k, v)
218
+ self._session_headers()
91
219
  self.end_headers()
92
220
  self.wfile.write(body)
93
221
 
@@ -129,6 +257,10 @@ class Handler(BaseHTTPRequestHandler):
129
257
  return self._api_prompts(self._dataset(qs))
130
258
  if path == "/api/schema":
131
259
  return self._api_schema(self._dataset(qs))
260
+ if path == "/api/workshop/state":
261
+ return self._api_workshop_state()
262
+ if path.startswith("/api/workshop/job/"):
263
+ return self._api_workshop_job(path.rsplit("/", 1)[1])
132
264
  if path == "/api/export/fields":
133
265
  return self._api_export_fields(self._dataset(qs), qs)
134
266
  if path in ("/api/export/marc", "/api/export/dc"):
@@ -164,6 +296,10 @@ class Handler(BaseHTTPRequestHandler):
164
296
  return self._api_export_catalogue(
165
297
  self._dataset(qs), u.path.rsplit("/", 1)[1], qs, mapping=body.get("mapping")
166
298
  )
299
+ if u.path == "/api/workshop/state":
300
+ return self._api_workshop_save()
301
+ if u.path == "/api/workshop/run":
302
+ return self._api_workshop_run()
167
303
  if u.path == "/api/oauth/hf/exchange":
168
304
  return self._api_hf_exchange()
169
305
  if u.path == "/api/export/hf":
@@ -623,6 +759,40 @@ class Handler(BaseHTTPRequestHandler):
623
759
  )
624
760
 
625
761
 
762
+ def _workshop_defaults(datasets: list[dict], endpoint: dict) -> dict:
763
+ """Seed an attendee's starting prompt and fields from the newest round being
764
+ served, so the Space needs no separate copy of either. The endpoint comes
765
+ from the usual config/env layer."""
766
+ newest = max(datasets, key=lambda d: d.get("round") or 0, default=None)
767
+ prompt, fields, project = "", [], "workshop"
768
+ if newest:
769
+ project = newest.get("base") or project
770
+ try:
771
+ prov = json.loads((newest["dir"] / "provenance.json").read_text())
772
+ prompt = prov.get("prompt", "")
773
+ except (OSError, json.JSONDecodeError, KeyError):
774
+ pass
775
+ try:
776
+ view = json.loads((newest["dir"] / "view.json").read_text())
777
+ for panel in view.get("panels", []):
778
+ if panel.get("source") == "model_output":
779
+ fields = [
780
+ {"name": f["key"], "type": "text", "description": ""}
781
+ for f in panel.get("fields", [])
782
+ ]
783
+ except (OSError, json.JSONDecodeError):
784
+ pass
785
+ return {
786
+ "prompt": prompt,
787
+ "fields": fields,
788
+ "project": project,
789
+ "source": endpoint.get("source"),
790
+ "base_url": endpoint.get("base_url"),
791
+ "api_key": endpoint.get("api_key") or "EMPTY",
792
+ "model": endpoint.get("model"),
793
+ }
794
+
795
+
626
796
  def serve(
627
797
  data_dir: Path,
628
798
  port: int = DEFAULT_PORT,
@@ -630,6 +800,8 @@ def serve(
630
800
  host: str = "127.0.0.1",
631
801
  db_path: Path | None = None,
632
802
  allow_empty: bool = False,
803
+ workshop: Path | str | None = None,
804
+ endpoint: dict | None = None,
633
805
  ) -> None:
634
806
  """Serve the review UI over ``data_dir``.
635
807
 
@@ -648,11 +820,20 @@ def serve(
648
820
  f"or pass a dataset directory: paratext review <dir>"
649
821
  )
650
822
  data_dir.mkdir(parents=True, exist_ok=True)
651
- Handler.data_dir = data_dir
823
+ Handler.base_data_dir = data_dir
652
824
  # Default the annotation store to <data_dir>/annotations.db; --db can point it
653
825
  # elsewhere (e.g. an existing DB when swapping in for another review app).
654
- Handler.store = Store(default_db_path(data_dir, Path(db_path).resolve() if db_path else None))
826
+ Handler.base_store = Store(
827
+ default_db_path(data_dir, Path(db_path).resolve() if db_path else None)
828
+ )
655
829
  datasets = discover_datasets(data_dir)
830
+ # Workshop mode: every browser gets its own workspace, seeded with the rounds
831
+ # shipped here so an attendee's own runs sit beside the worked example.
832
+ Handler.sessions = (
833
+ Sessions(Path(workshop).resolve(), examples=[d["dir"] for d in datasets])
834
+ if workshop
835
+ else None
836
+ )
656
837
  httpd = ThreadingHTTPServer((host, port), Handler)
657
838
  # When bound to all interfaces there's no single canonical URL; show
658
839
  # localhost for clicking and note the bind address.
@@ -663,6 +844,12 @@ def serve(
663
844
  print(
664
845
  f" data dir: {data_dir} ({len(datasets)} dataset(s): {names})"
665
846
  )
847
+ if workshop:
848
+ Handler.workshop_defaults = _workshop_defaults(datasets, endpoint or {})
849
+ d = Handler.workshop_defaults
850
+ print(f" workshop mode: per-session workspaces under {Handler.sessions.root}")
851
+ print(f" endpoint {d.get('model') or '(no model set)'} at {d.get('base_url')}")
852
+ print(f" source {d.get('source') or '(none — runs disabled)'}")
666
853
  if open_browser:
667
854
  try:
668
855
  webbrowser.open(url)