Deploy Arabic Audio Reader worker
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .export-complete +1 -0
- .export-manifest.json +105 -0
- Dockerfile +68 -0
- README.md +60 -10
- api/index.py +2 -0
- app/__init__.py +1 -0
- app/main.py +0 -0
- docs/best-free-arabic-pdf-audio-stack.md +616 -0
- docs/father-user-guide.md +51 -0
- docs/huggingface-model-metadata.md +81 -0
- docs/live-deployment-checklist.md +317 -0
- docs/production-worker-architecture.md +224 -0
- docs/recommended-decision-card.json +138 -0
- docs/recommended-decision-card.md +70 -0
- docs/recommended-free-stack.md +86 -0
- docs/research-watchlist.md +0 -0
- docs/source-evidence.md +146 -0
- requirements-arabic-glm-ocr.txt +4 -0
- requirements-arabic-qwen-ocr.txt +5 -0
- requirements-baseer-ocr.txt +6 -0
- requirements-katib-ocr.txt +5 -0
- requirements-paddleocr-vl.txt +2 -0
- requirements-paddleocr.txt +2 -0
- requirements-qari-ocr.txt +7 -0
- requirements-silma.txt +33 -0
- requirements-supertonic.txt +1 -0
- requirements-tawkeed-ocr.txt +6 -0
- requirements.txt +7 -0
- scripts/arabic_glm_ocr_extract.py +85 -0
- scripts/arabic_qwen_ocr_extract.py +120 -0
- scripts/audit_goal_readiness.py +504 -0
- scripts/baseer_ocr_extract.py +102 -0
- scripts/benchmark_ocr.py +276 -0
- scripts/benchmark_voices.py +213 -0
- scripts/check_deployment_readiness.py +689 -0
- scripts/check_research_sources.py +1213 -0
- scripts/check_test_environment.py +31 -0
- scripts/cleanup_outputs.py +35 -0
- scripts/configure_vercel_worker.py +165 -0
- scripts/deploy_hf_space.py +160 -0
- scripts/deployment_handoff.py +336 -0
- scripts/deployment_status.py +256 -0
- scripts/dry_run_pdf.py +307 -0
- scripts/easyocr_extract.py +39 -0
- scripts/export_hf_space.py +335 -0
- scripts/export_ocr_sample_images.py +456 -0
- scripts/export_tts_sample.py +387 -0
- scripts/finish_live_deployment.py +180 -0
- scripts/hosted_preflight.py +125 -0
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.env
|
| 3 |
+
.venv
|
| 4 |
+
.venv-*
|
| 5 |
+
__pycache__
|
| 6 |
+
.pytest_cache
|
| 7 |
+
outputs
|
| 8 |
+
uploads
|
| 9 |
+
data
|
| 10 |
+
test_pdfs
|
| 11 |
+
tests
|
| 12 |
+
*.pyc
|
| 13 |
+
*.pyo
|
| 14 |
+
*.pyd
|
| 15 |
+
*.log
|
.export-complete
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ready
|
.export-manifest.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": {
|
| 3 |
+
"Dockerfile": "9ec939ea5ce45fcd8d1cd1c72df61087be9e27630dad09f46d0d6b6ac56606f0",
|
| 4 |
+
"api/index.py": "b0fd5c43eadd241aea79131d12ea40fe032a97f06410ce1b607e81c45f33d6f2",
|
| 5 |
+
"app/__init__.py": "7eb70257593da06f682a3ddda54a9d260d4fc514f645237f5ca74b08f8da61a6",
|
| 6 |
+
"app/main.py": "96d3dd5ea42e6328c9891b8215bb5bb092a2c63980781b08e86524a7f27e3bd9",
|
| 7 |
+
"docs/best-free-arabic-pdf-audio-stack.md": "d73c1c1fca7a04517ae1abecb893175e238d36318c39c0755e9b4fa570d3910a",
|
| 8 |
+
"docs/father-user-guide.md": "cf5a955e30ecf8fa0c0179995cb9e90106cc9335a5994eb8b8368a8164c4d021",
|
| 9 |
+
"docs/huggingface-model-metadata.md": "4590229078c2048b184787e85e5a00dd687ef5fc90e8d8d0af32538b38363cc2",
|
| 10 |
+
"docs/live-deployment-checklist.md": "390f89084febb94489996a83287e3d3e97d67f996a648c7c0f79bbe8f510fbf8",
|
| 11 |
+
"docs/production-worker-architecture.md": "a1776a7db4397103ef4f4e7ae82588dfd01576a5b8aca7b22d25514c3ccfce8e",
|
| 12 |
+
"docs/recommended-decision-card.json": "78213faeeb8825a7d2c4bf4fe0886b282b6e9a9d522fc742aab54a55f6efb1b3",
|
| 13 |
+
"docs/recommended-decision-card.md": "0e353fda7e9dc410ec031a2f4ef10398cb5ce8b00aa0ddc03479995ef467a18f",
|
| 14 |
+
"docs/recommended-free-stack.md": "d2da77822fe5f94790ed560323b2c1c8cf7a8303315a973183633245aea1e491",
|
| 15 |
+
"docs/research-watchlist.md": "9ea43e6f3f1d434e514e451ccc8974faa469f4533d0ffe924c8db3d80755e592",
|
| 16 |
+
"docs/source-evidence.md": "f308cbd0dc83a5cf34ceb5a010c354cda9acbc690e9b2c93a80cdb519ec07976",
|
| 17 |
+
"requirements-arabic-glm-ocr.txt": "b4c950c1ef221bfe6e2deda1a93605377619059eb02019931dbfe1fe7bd49e10",
|
| 18 |
+
"requirements-arabic-qwen-ocr.txt": "c19f907a3834255ff9e9588df158a3cb772ce61e7234ddd6508fbfcd9b9188f2",
|
| 19 |
+
"requirements-baseer-ocr.txt": "e5ba1652d94d98b99b180330a198ac857c1c7b556ef3d7c5a0da1252ea33ec76",
|
| 20 |
+
"requirements-katib-ocr.txt": "47defded2d099f26caf3260a9764967b8c00e61d159949873325adfe9e5bcd27",
|
| 21 |
+
"requirements-paddleocr-vl.txt": "425f83d985be5751b624a11023110a6aef6f23339a00f90aaec9d92572b5c517",
|
| 22 |
+
"requirements-paddleocr.txt": "94de53878d0f730907ddcb37fb735714d5c83beb069e308d935002df850f3d3c",
|
| 23 |
+
"requirements-qari-ocr.txt": "71cafe3617954a657fa7fc983b330ba37899aa4ba95a43d3e7f8590ff0fd5be5",
|
| 24 |
+
"requirements-silma.txt": "63c10a7c78fcd65af653f3afd48dd031af7695b1b8545684efdef32dd814a872",
|
| 25 |
+
"requirements-supertonic.txt": "87b6e0fa01626aa259aa99eed3b0d9b57867cab7b2a1a37ea05de817f4a285b6",
|
| 26 |
+
"requirements-tawkeed-ocr.txt": "5e718dc14b8a2aa7e6269d63a85a37e15892d5863b42608e928e5217233edb22",
|
| 27 |
+
"requirements.txt": "59d736ba33b31a828a5987f3477bec3f7ed6f60ceafaf730ef027a0dbbcd0def",
|
| 28 |
+
"scripts/arabic_glm_ocr_extract.py": "f56578018b81ac8cd7928baa9576878791214e5659f972520e8817370a9d39ad",
|
| 29 |
+
"scripts/arabic_qwen_ocr_extract.py": "485e9f3cdf2ced92c666b2f483d4aa37a65cb34052a4967beac7183d02c9ddcc",
|
| 30 |
+
"scripts/audit_goal_readiness.py": "18835de059c01515e94813c8b32105da47c08c66126dad8275956c0213bfc958",
|
| 31 |
+
"scripts/baseer_ocr_extract.py": "056ca9cc33591db804639030a16d9635931b720d0d499b444ed6e7d0a653605a",
|
| 32 |
+
"scripts/benchmark_ocr.py": "b5ffb17845a7945b2a5c52e38bfabb6d82f3a8fbc8f2cdd5528843e09ad4deb4",
|
| 33 |
+
"scripts/benchmark_voices.py": "705bdfb6260fe90a4a68d9d2455953ea7221d282bbf0cc1cc4fa32cd5ed10205",
|
| 34 |
+
"scripts/check_deployment_readiness.py": "13ec851a25b1fde5f0e3f0ea0635d4145b0296a5acb28c6d9a61f286499eeba3",
|
| 35 |
+
"scripts/check_research_sources.py": "1db463ada18c6b6062f096fdc9f4b0f9fbdd9365d785265da3acdc45e1da0d88",
|
| 36 |
+
"scripts/check_test_environment.py": "7150b13aabad03a9b7ae2527f7cdd942511658eeafb76b41128eab7e0a6dc1ea",
|
| 37 |
+
"scripts/cleanup_outputs.py": "de8beacd9b8511dc3775d9c232a2c86dc5cbe91c532cb3c130c304117f0d6bfd",
|
| 38 |
+
"scripts/configure_vercel_worker.py": "b14067cf58d1bdc3243580532661369fcf70e7fe6f5fba33ee7329de924e058f",
|
| 39 |
+
"scripts/deploy_hf_space.py": "dd42220e78d025c68bbd52750c4af4187e080af62566c78a3f032698f72a755b",
|
| 40 |
+
"scripts/deployment_handoff.py": "acaab15aa32e0cd3b4f969e5a6fcb013bb9db24ce59215626e5f5809792c4713",
|
| 41 |
+
"scripts/deployment_status.py": "290e4fe5324473c8aaf5701e50a816cf006a1400e8a458f0e1a443b9f7393ae6",
|
| 42 |
+
"scripts/dry_run_pdf.py": "f162b566fb51d824d484a479a1337d4ee7e9a6762c0f2ccb5acd3173c1cf1bf8",
|
| 43 |
+
"scripts/easyocr_extract.py": "5a728a80bac7d49281113410316b5004cb6538ad50d1bc7c431eaea9c006ada9",
|
| 44 |
+
"scripts/export_hf_space.py": "121f817b9ece4ae7a85f4aba3b2c5323c9e67bbd7e22e1b95cfd95599eea73bc",
|
| 45 |
+
"scripts/export_ocr_sample_images.py": "eaf2ed2dca63f649317d283a1339ddae64d79b8d79eb42fe601d3b4a92ce8f45",
|
| 46 |
+
"scripts/export_tts_sample.py": "477ae98e81c60bc3336012167355b0b8724cd1988047e2af41f443b23de7e9f3",
|
| 47 |
+
"scripts/finish_live_deployment.py": "11b90f7f96bf5315220c8a4daef23722b73edc2133a26a93c12793d9e222163f",
|
| 48 |
+
"scripts/hosted_preflight.py": "931bae01a0e18b4c0490489c5e4eef463c92cce980c2d901705cbf4803c7f291",
|
| 49 |
+
"scripts/katib_ocr_extract.py": "cd667d9f242b6b348ddca99dd66045a24b1446e371995122b517f30eb24f6fcc",
|
| 50 |
+
"scripts/model_promotion_gate.py": "e0ecec8d8d437dfbae05d157e3e40aaeef8c34c56d80c80624521a5b305ff690",
|
| 51 |
+
"scripts/next_deployment_step.py": "5fbb188bc79fb1c98f1623e463ce0cf99c2b2fe9d8b249dcee94448bbf407b80",
|
| 52 |
+
"scripts/paddleocr_extract.py": "30637be8aed0f7951c8bd450274cf0f68b516b3c38de51b37f9b9195c56813a1",
|
| 53 |
+
"scripts/paddleocr_vl_extract.py": "8089d2e414bd0183d8593a4a51513d1914a3172776369882e6d5bbd8f541ee4a",
|
| 54 |
+
"scripts/preflight_check.py": "f7e7a4c6f2e96442ec08abb0bf0828cfc4b27edb672f64cdb886cb15095ff0c9",
|
| 55 |
+
"scripts/prepare_book_workflow.py": "b24c84de91b6912360c8c9bb7059988d2ce798c840260a53299b6d93b9d87e86",
|
| 56 |
+
"scripts/prepare_live_deployment.py": "e32f9463812d20e7e7a8cd6b7247142ced7491cdf2bfb866c2000443d4790239",
|
| 57 |
+
"scripts/prove_live_deployment.py": "d1fed48fdb902349b1ea99dd7f2d651bb4131f4c915e25ed3691b40cd5099675",
|
| 58 |
+
"scripts/prove_local_readiness.py": "7ccb00fc2d4aa086f8cec5ac9886f87fe044eb46c6f9a7fa7c5eb095d44095ad",
|
| 59 |
+
"scripts/qari_ocr_extract.py": "82ac22dae63e415c9795f4f597c000beba32af028b0a5afc749ba11bfebe2b22",
|
| 60 |
+
"scripts/refresh_research_evidence.py": "52209edf8485ff459bda6ada6cb1b978f12b22cdacb03413d293dab9245303fc",
|
| 61 |
+
"scripts/research_watchlist.py": "3d7640a8777b81ef596f1a24c6a4c8560233c396e09190de8cb739f2a405d37c",
|
| 62 |
+
"scripts/score_external_ocr.py": "e4b36187949dd38eaa9395979a97ccef88f7aff24a1404e3bf0793776eea136f",
|
| 63 |
+
"scripts/score_tts_preprocessor.py": "7b9afce94bbc914b884a09bb83bd4d267770f6b712ffb5721cfef7c34e2718a2",
|
| 64 |
+
"scripts/score_voice_listening.py": "22287145ab5677c4e4383a01dc9cb2090f3f22e20a37e0902bcfed8df7c7e5f6",
|
| 65 |
+
"scripts/select_test_pages.py": "b97f0599cb2a1371737ebd7110c62dde085377f35ce39b78413848f21a20d5c7",
|
| 66 |
+
"scripts/setup_arabic_glm_ocr.ps1": "5d0039918a41cd925bcff02a36aa627051abfd120665a681297a64ae7fe505e6",
|
| 67 |
+
"scripts/setup_arabic_glm_ocr.sh": "7040b741f39ad6f8fbc438fb2f5bc5a95d01704f483d64be29f1ceeb74a768c2",
|
| 68 |
+
"scripts/setup_arabic_qwen_ocr.ps1": "b2363d4eefb1e7308681f559c1ff98c898c82877d5daf1148408db704a760c64",
|
| 69 |
+
"scripts/setup_arabic_qwen_ocr.sh": "a6195851025c319f714ea499ea02ba18471c4ae41052c6ba307ee9def0ee122b",
|
| 70 |
+
"scripts/setup_baseer_ocr.ps1": "26789f174fcf3e65ef013897c6a98d92744ede6a61db0d2eb93f66a5889a3029",
|
| 71 |
+
"scripts/setup_baseer_ocr.sh": "ff242dbfdbe1bb8b5a7d9ebf5d2ed891c23646eddadd470222c9135795fa7998",
|
| 72 |
+
"scripts/setup_habibi.ps1": "5c0c4cfd1367169be10a68ea404b3ca3023ebf2ac140db82d5df36b76b6ba682",
|
| 73 |
+
"scripts/setup_habibi.sh": "a737e7a8266fa47eb1eba3deeed52ff2bc91042646fff3b721afe7edefaf41d8",
|
| 74 |
+
"scripts/setup_katib_ocr.ps1": "10b3e2a0781bcccec8a344f79b2639d13abcf26904025bf91ed1646fec34115e",
|
| 75 |
+
"scripts/setup_katib_ocr.sh": "ee59fccd22a76c1d773c40d1e734b6f33e12d04a3e65294c635892d019c9f673",
|
| 76 |
+
"scripts/setup_paddleocr.ps1": "afaaa71132582aa7bbaaa6cf45ebc6bfffe716ca542da66c0f9e7bb849dca689",
|
| 77 |
+
"scripts/setup_paddleocr.sh": "2365608707febc705a88df84d4323fae619020011e71cb1f3d0346bcf6deb548",
|
| 78 |
+
"scripts/setup_paddleocr_vl.ps1": "0840fc9f181b246bff754bff6ad6c28a2aaf80fc0771a745f0577737dea3a806",
|
| 79 |
+
"scripts/setup_paddleocr_vl.sh": "38d048a154d8c55e9ab4c068a7177e33618d921951c753745ab54312ec24e1ea",
|
| 80 |
+
"scripts/setup_qari_ocr.ps1": "d8dc9e30df59476dfc737d2538d84523a694c97122196690d175f13e5b5e9e6b",
|
| 81 |
+
"scripts/setup_qari_ocr.sh": "001792fe72c2b0c1f8015870b964ae0e45a9804b3da275b76e0d06e7b5bf3ead",
|
| 82 |
+
"scripts/setup_silma.ps1": "6f5219b837f47442a1f60213f4385bf8d79aa7dcfa35c8832c082efef7443ba6",
|
| 83 |
+
"scripts/setup_silma.sh": "752292c14b89f477376a23490e0d8794aa8749b3b2250833c63d0c41003d09a6",
|
| 84 |
+
"scripts/setup_supertonic.ps1": "f7982df82b13b2990d7e7505783eba170803abe7209584f7c332fbb82e828140",
|
| 85 |
+
"scripts/setup_supertonic.sh": "dd9e9a7239d87fdb5f9ee59cbde5562ab5935de7a886dd42a45d6b668d85e30d",
|
| 86 |
+
"scripts/setup_surya.ps1": "a36c540d7c6a2b3c8b65f6f4db233f684c01b168c37d3e75eca33f58ee52360a",
|
| 87 |
+
"scripts/setup_surya.sh": "59fb9ada4d50c51df2ed16ca748274691bd256f10fc8f58ddb58a228de9d5365",
|
| 88 |
+
"scripts/setup_tawkeed_ocr.ps1": "6cada07cb5e21c4a943d709f6012d230ba93c9890f7699cf38a00433bbd593b6",
|
| 89 |
+
"scripts/setup_tawkeed_ocr.sh": "fd2326592b1adb96bc6d94a434a0ed61ef67183791ef8fc00b1cbdaeb362d619",
|
| 90 |
+
"scripts/silma_synthesize.py": "20a9d891e9b235008e3ff017103c1cb56e0a0e16eb762dc6c6649c0cf8b2875e",
|
| 91 |
+
"scripts/supertonic_synthesize.py": "8223e3982de99e06091cff419d9b4584a56823b67c94b1493ce7143dd8c7f4f4",
|
| 92 |
+
"scripts/surya_extract.py": "7361a8a667779c46aed71fa67b7f869d16f8067b55591d202fa968b8fc7628d7",
|
| 93 |
+
"scripts/tawkeed_ocr_extract.py": "da554d5620237b70e234032b5525fcee8e9bebe9a924e5750746530c41972318",
|
| 94 |
+
"scripts/validate_deployment_env.py": "eee9ddcf8ddb24c7709657b5c2584f73afeafd2caac730990e979a28ad37103e",
|
| 95 |
+
"scripts/verify_pipeline.py": "57359e0e4399352976100f633ce780d9a022e96885e18d29d7f5bd4c4a43a857",
|
| 96 |
+
"scripts/verify_site.py": "883e682cf6f9c245e1581eeee89fd4178354608123f57d0206f271a04cb8edf6",
|
| 97 |
+
"scripts/verify_voice.py": "d8fb7e473e47060b2d2f957c5c230807a205e95b1469eef9c32b76d2bc8585b2",
|
| 98 |
+
"scripts/verify_worker.py": "751f74c6f21694c74049877ff498cce200897be1131e853f9388891d8b047066",
|
| 99 |
+
"static/app.js": "9adb1801253687d833d8cb9edca833c1ea3ce54a74bacfb2747dbfeb31647118",
|
| 100 |
+
"static/index.html": "23d74fcfe46c93a7e2b6c12b1916b4567745ab18d52c41e4252ab4993f6d251e",
|
| 101 |
+
"static/styles.css": "a45485cf99eaae8a46e57437a736ce1ebad2528dbf219c5bc79f124ec3c47164"
|
| 102 |
+
},
|
| 103 |
+
"source": "ArabicTranslator",
|
| 104 |
+
"version": 1
|
| 105 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PIP_NO_CACHE_DIR=1 \
|
| 5 |
+
WORK_DIR=/data/arabic-translator \
|
| 6 |
+
DATABASE_PATH=/data/arabic-translator/data/arabic_reader.sqlite3 \
|
| 7 |
+
TESSDATA_DIR=/usr/share/tesseract-ocr/5/tessdata \
|
| 8 |
+
ESPEAK_NG_EXE=/usr/bin/espeak-ng \
|
| 9 |
+
OCR_ENGINE=arabic-max \
|
| 10 |
+
DEFAULT_VOICE_ID=silma-local \
|
| 11 |
+
MAX_UPLOAD_MB=512 \
|
| 12 |
+
OUTPUT_RETENTION_DAYS=7 \
|
| 13 |
+
OUTPUT_MAX_FILES=25 \
|
| 14 |
+
AUDIO_FORMAT=mp3 \
|
| 15 |
+
MP3_BITRATE=96k \
|
| 16 |
+
SILMA_FULL_NORMALIZER_DEPS=0 \
|
| 17 |
+
SILMA_ENABLE_NORMALIZER=0 \
|
| 18 |
+
SILMA_FORCE_TASHKEEL=0 \
|
| 19 |
+
SILMA_NORMALIZE_NUMBERS=0
|
| 20 |
+
|
| 21 |
+
ARG INSTALL_QARI_OCR=0
|
| 22 |
+
ARG INSTALL_TAWKEED_OCR=0
|
| 23 |
+
ARG INSTALL_KATIB_OCR=0
|
| 24 |
+
ARG INSTALL_ARABIC_QWEN_OCR=0
|
| 25 |
+
ARG INSTALL_ARABIC_GLM_OCR=0
|
| 26 |
+
ARG INSTALL_BASEER_OCR=0
|
| 27 |
+
ARG INSTALL_PADDLEOCR_VL=0
|
| 28 |
+
ARG INSTALL_SUPERTONIC=0
|
| 29 |
+
|
| 30 |
+
WORKDIR /app
|
| 31 |
+
|
| 32 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 33 |
+
bash \
|
| 34 |
+
espeak-ng \
|
| 35 |
+
ffmpeg \
|
| 36 |
+
libsndfile1 \
|
| 37 |
+
tesseract-ocr \
|
| 38 |
+
tesseract-ocr-ara \
|
| 39 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 40 |
+
|
| 41 |
+
COPY requirements.txt requirements-silma.txt requirements-supertonic.txt requirements-paddleocr.txt requirements-paddleocr-vl.txt requirements-qari-ocr.txt requirements-tawkeed-ocr.txt requirements-katib-ocr.txt requirements-arabic-qwen-ocr.txt requirements-arabic-glm-ocr.txt requirements-baseer-ocr.txt ./
|
| 42 |
+
COPY scripts/setup_silma.sh scripts/setup_supertonic.sh scripts/setup_paddleocr.sh scripts/setup_paddleocr_vl.sh scripts/setup_qari_ocr.sh scripts/setup_tawkeed_ocr.sh scripts/setup_katib_ocr.sh scripts/setup_arabic_qwen_ocr.sh scripts/setup_arabic_glm_ocr.sh scripts/setup_baseer_ocr.sh ./scripts/
|
| 43 |
+
|
| 44 |
+
RUN python -m venv .venv \
|
| 45 |
+
&& .venv/bin/python -m pip install --upgrade pip \
|
| 46 |
+
&& .venv/bin/python -m pip install -r requirements.txt \
|
| 47 |
+
&& chmod +x scripts/setup_silma.sh scripts/setup_supertonic.sh scripts/setup_paddleocr.sh scripts/setup_paddleocr_vl.sh scripts/setup_qari_ocr.sh scripts/setup_tawkeed_ocr.sh scripts/setup_katib_ocr.sh scripts/setup_arabic_qwen_ocr.sh scripts/setup_arabic_glm_ocr.sh scripts/setup_baseer_ocr.sh \
|
| 48 |
+
&& PYTHON_BIN=python scripts/setup_silma.sh \
|
| 49 |
+
&& PYTHON_BIN=python scripts/setup_paddleocr.sh \
|
| 50 |
+
&& if [ "$INSTALL_SUPERTONIC" = "1" ]; then PYTHON_BIN=python scripts/setup_supertonic.sh; fi \
|
| 51 |
+
&& if [ "$INSTALL_QARI_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_qari_ocr.sh; fi \
|
| 52 |
+
&& if [ "$INSTALL_TAWKEED_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_tawkeed_ocr.sh; fi \
|
| 53 |
+
&& if [ "$INSTALL_KATIB_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_katib_ocr.sh; fi \
|
| 54 |
+
&& if [ "$INSTALL_ARABIC_QWEN_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_arabic_qwen_ocr.sh; fi \
|
| 55 |
+
&& if [ "$INSTALL_ARABIC_GLM_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_arabic_glm_ocr.sh; fi \
|
| 56 |
+
&& if [ "$INSTALL_BASEER_OCR" = "1" ]; then PYTHON_BIN=python scripts/setup_baseer_ocr.sh; fi \
|
| 57 |
+
&& if [ "$INSTALL_PADDLEOCR_VL" = "1" ]; then PYTHON_BIN=python scripts/setup_paddleocr_vl.sh; fi
|
| 58 |
+
|
| 59 |
+
COPY app ./app
|
| 60 |
+
COPY api ./api
|
| 61 |
+
COPY static ./static
|
| 62 |
+
COPY scripts ./scripts
|
| 63 |
+
|
| 64 |
+
RUN mkdir -p /data/arabic-translator/uploads /data/arabic-translator/outputs /data/arabic-translator/data
|
| 65 |
+
|
| 66 |
+
EXPOSE 7860
|
| 67 |
+
|
| 68 |
+
CMD [".venv/bin/python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,60 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Arabic Audio Reader Worker
|
| 2 |
+
|
| 3 |
+
This is the Docker worker bundle for the Arabic PDF Reader.
|
| 4 |
+
|
| 5 |
+
## Hugging Face Space Settings
|
| 6 |
+
|
| 7 |
+
- SDK: Docker
|
| 8 |
+
- Hardware: free CPU is acceptable for demos, but cold starts and long books can be slow
|
| 9 |
+
- Free CPU Basic currently provides 2 vCPU, 16 GB RAM, and 50 GB non-persistent disk by default; treat generated audio as short-lived unless you add persistent/object storage
|
| 10 |
+
- Port: 7860
|
| 11 |
+
- Default build: installs SILMA, PaddleOCR Arabic, Tesseract Arabic, and eSpeak NG
|
| 12 |
+
- Optional fast CPU voice: set Docker build arg `INSTALL_SUPERTONIC=1` to add Supertonic 3 Arabic-capable local TTS
|
| 13 |
+
- Stronger OCR build: set Docker build arg `INSTALL_TAWKEED_OCR=1`, `INSTALL_KATIB_OCR=1`, `INSTALL_ARABIC_QWEN_OCR=1`, `INSTALL_ARABIC_GLM_OCR=1`, or `INSTALL_BASEER_OCR=1` for Arabic-trained models, or `INSTALL_QARI_OCR=1` for the heavier Arabic-book model
|
| 14 |
+
|
| 15 |
+
Set these Space secrets:
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
ACCESS_CODE=1234
|
| 19 |
+
SECRET_KEY=<generated by outputs\deployment-handoff.md>
|
| 20 |
+
CORS_ORIGINS=https://your-vercel-app.vercel.app
|
| 21 |
+
COOKIE_SAMESITE=none
|
| 22 |
+
COOKIE_SECURE=1
|
| 23 |
+
OCR_ENGINE=arabic-max
|
| 24 |
+
DEFAULT_VOICE_ID=silma-local
|
| 25 |
+
OUTPUT_RETENTION_DAYS=7
|
| 26 |
+
OUTPUT_MAX_FILES=25
|
| 27 |
+
AUDIO_FORMAT=mp3
|
| 28 |
+
MP3_BITRATE=96k
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Generate the deployment handoff from the main repo to get the exact `SECRET_KEY`, worker secrets, Vercel environment variables, and final proof command:
|
| 32 |
+
|
| 33 |
+
```powershell
|
| 34 |
+
python scripts\deployment_handoff.py https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
Keep `outputs\deployment-handoff.md` private because it contains deployment secrets.
|
| 38 |
+
|
| 39 |
+
The compact process recommendation is included at `docs/recommended-free-stack.md`, with the machine-readable deployment decision card at `docs/recommended-decision-card.json` and its readable companion at `docs/recommended-decision-card.md`. The current practical default is PyMuPDF embedded text first, `OCR_ENGINE=arabic-max` for scanned Arabic pages, SILMA TTS for the first clean voice, and downloadable worker audio.
|
| 40 |
+
|
| 41 |
+
Optional stronger-worker build args:
|
| 42 |
+
|
| 43 |
+
```text
|
| 44 |
+
INSTALL_QARI_OCR=1
|
| 45 |
+
INSTALL_TAWKEED_OCR=1
|
| 46 |
+
INSTALL_KATIB_OCR=1
|
| 47 |
+
INSTALL_ARABIC_QWEN_OCR=1
|
| 48 |
+
INSTALL_ARABIC_GLM_OCR=1
|
| 49 |
+
INSTALL_BASEER_OCR=1
|
| 50 |
+
INSTALL_PADDLEOCR_VL=1
|
| 51 |
+
INSTALL_SUPERTONIC=1
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Use `INSTALL_TAWKEED_OCR=1`, `INSTALL_KATIB_OCR=1`, `INSTALL_ARABIC_QWEN_OCR=1`, `INSTALL_ARABIC_GLM_OCR=1`, or `INSTALL_BASEER_OCR=1` first when you want an Arabic-trained OCR model. Use `INSTALL_QARI_OCR=1` when you want the strongest Arabic-book OCR and the worker has enough memory/GPU. Leave heavy options at `0` on free CPU Spaces unless a short benchmark proves the stronger model is worth the cold start, build time, memory, and runtime.
|
| 55 |
+
|
| 56 |
+
After the Space builds, verify it from your main repo:
|
| 57 |
+
|
| 58 |
+
```powershell
|
| 59 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max
|
| 60 |
+
```
|
api/index.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.main import app
|
| 2 |
+
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/main.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docs/best-free-arabic-pdf-audio-stack.md
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Best Free Arabic PDF-To-Audio Stack
|
| 2 |
+
|
| 3 |
+
Last checked: June 7, 2026.
|
| 4 |
+
|
| 5 |
+
The source evidence is summarized in `docs/source-evidence.md`; verify the research markers with `python scripts\check_research_sources.py --json`.
|
| 6 |
+
|
| 7 |
+
## Recommendation
|
| 8 |
+
|
| 9 |
+
For this project, the best practical free local stack is:
|
| 10 |
+
|
| 11 |
+
1. PyMuPDF for embedded PDF text.
|
| 12 |
+
2. `OCR_ENGINE=arabic-max` for scanned pages by default; it compares the strongest available Arabic OCR outputs and chooses the cleanest text. When QARI-OCR, Tawkeed, KATIB, Arabic-Qwen, or Baseer is installed, this includes Arabic-trained VLM OCR candidates.
|
| 13 |
+
3. EasyOCR Arabic as a strong alternate for older scans and difficult layouts.
|
| 14 |
+
4. `OCR_ENGINE=best` for short quality tests, which compares the free local OCR engines and picks the best-looking Arabic text.
|
| 15 |
+
5. QARI-OCR as the optional Arabic-native heavy OCR path for strong workers.
|
| 16 |
+
6. Tesseract Arabic as the final fallback.
|
| 17 |
+
7. Speech cleanup that removes repeated headers, page numbers, digit-heavy OCR debris, symbol noise, and tiny layout fragments before TTS.
|
| 18 |
+
8. SILMA TTS for the best local Arabic voice.
|
| 19 |
+
9. Habibi-TTS MSA as an optional newer Arabic voice experiment.
|
| 20 |
+
10. 3arab-TTS 500M as a very new Arabic-only Apache-2.0 voice benchmark.
|
| 21 |
+
11. KaniTTS Arabic as a high-speed Arabic voice benchmark that needs license confirmation.
|
| 22 |
+
12. Emirati VITS Male as an Apache-2.0 Gulf dialect voice benchmark.
|
| 23 |
+
13. MOSS-TTS-Nano as an optional CPU-friendly multilingual Arabic voice benchmark.
|
| 24 |
+
14. OmniVoice as a fresh optional multilingual Arabic voice benchmark.
|
| 25 |
+
15. Lahgtna OmniVoice v2 as a new dialect-only OmniVoice benchmark candidate.
|
| 26 |
+
16. Lahgtna Chatterbox as an optional Arabic dialect voice benchmark.
|
| 27 |
+
17. NAMAA-Saudi-TTS as an MIT Saudi Arabic Chatterbox benchmark.
|
| 28 |
+
18. Egyptian Arabic Chatterbox as an Apache-2.0 dialect-only Chatterbox benchmark.
|
| 29 |
+
19. NileTTS-XTTS as an Apache-2.0 Egyptian Arabic XTTS benchmark.
|
| 30 |
+
20. Arabic XTTS-v2 Egyptian fine-tune as a CPML/base-license dialect benchmark.
|
| 31 |
+
21. Chatterbox-Multilingual ONNX as an MIT CPU/ONNX Arabic-capable voice benchmark.
|
| 32 |
+
21. tts-arabic-onnx as a compact Arabic-only ONNX voice benchmark with license confirmation required.
|
| 33 |
+
22. VoxCPM2 as an optional strong-worker multilingual Arabic voice benchmark.
|
| 34 |
+
23. Voxtral TTS as a personal/non-commercial strong-worker Arabic voice benchmark only.
|
| 35 |
+
24. Egyptian Arabic Qwen3-TTS as a dialect-only external benchmark.
|
| 36 |
+
25. Saudi Arabic Qwen3-TTS and Emirati Qwen3.5-TTS as newer Apache-2.0 Gulf dialect external benchmarks.
|
| 37 |
+
26. DeepSeek-OCR-2 as a latest Apache-2.0 general document OCR benchmark for strong workers.
|
| 38 |
+
27. DeepSeek Arabic OCR v6 as a newer Apache-2.0 Arabic-labeled DeepSeek-OCR external benchmark.
|
| 39 |
+
28. Qwen3-VL Persian/Arabic line OCR as a line-level OCR benchmark only, not a full-page book OCR default.
|
| 40 |
+
29. Arabic handwritten OCR 4-bit Qwen2.5-VL as a lighter Apache-2.0 handwriting/manuscript OCR benchmark.
|
| 41 |
+
30. NAKBA Arabic manuscript line OCR baseline as a line-level manuscript OCR benchmark only.
|
| 42 |
+
31. HAFITH as an Apache-2.0 historical Arabic manuscript line-OCR benchmark.
|
| 43 |
+
32. Glimpse RTL OCR as an Apache-2.0 Arabic/Persian text-line OCR benchmark.
|
| 44 |
+
33. olmOCR Arabic LoRA v2 as an Apache-2.0 full-page Arabic manuscript OCR benchmark.
|
| 45 |
+
34. Arabic OCR Qwen2.5-VL GGUF as a QariOCR-trained external GGUF/Unsloth benchmark for scanned books and mixed Arabic-English documents.
|
| 46 |
+
35. Arabic Legal Documents OCR 1.0 as a domain-specific Gemma-licensed OCR benchmark for legal/form-like Arabic PDFs.
|
| 47 |
+
36. oi-OCR as an Apache-2.0 Arabic-capable PDF/document parser benchmark for structured PDFs.
|
| 48 |
+
37. NuExtract3 as an Apache-2.0 multilingual document-to-Markdown OCR benchmark for hard layouts and multi-page PDFs.
|
| 49 |
+
38. Qianfan-OCR as an Apache-2.0 heavy multilingual document-intelligence OCR benchmark.
|
| 50 |
+
39. Chandra OCR 2 as a strong multilingual structured-document OCR benchmark with modified OpenRAIL-M weights.
|
| 51 |
+
40. dots.ocr as a compact MIT multilingual document-layout OCR benchmark.
|
| 52 |
+
41. Arabic Large Nougat as a GPL-3.0 Arabic book-page OCR-to-Markdown benchmark.
|
| 53 |
+
42. DocTR Arabic FAST/PARSEQ as a classic Arabic OCR detector/recognizer benchmark.
|
| 54 |
+
43. Kraken/eScriptorium Arabic script as a historical-print/manuscript OCR benchmark.
|
| 55 |
+
44. Kairawan/Qalamus manuscript OCR as a free service-only manuscript OCR signal, not a deployable default.
|
| 56 |
+
45. GLM-OCR Arabic/French documents as an administrative/form-like OCR benchmark.
|
| 57 |
+
46. mimoha Arabic OCR as a sparse-card low-priority Arabic OCR watchlist item.
|
| 58 |
+
47. Saudi Chatterbox fine-tune as an Apache-2.0 Saudi dialect voice benchmark.
|
| 59 |
+
48. Saudi TTS as an Apache-2.0 Saudi dialect voice benchmark.
|
| 60 |
+
49. eSpeak NG as the fast local fallback voice.
|
| 61 |
+
|
| 62 |
+
This is not the theoretically highest-accuracy Arabic OCR stack. The newest open OCR direction is moving toward vision-language document models, especially Arabic-specific VLMs such as QARI-OCR and general document parsers such as PaddleOCR-VL-1.6, but those are heavier than classic OCR and usually need a real worker with more RAM/GPU. For a free family website that needs to process 100 MB+ Arabic books reliably, the best practical default is still a lightweight OCR engine plus a per-book sample benchmark.
|
| 63 |
+
|
| 64 |
+
## Decision Matrix
|
| 65 |
+
|
| 66 |
+
| Layer | Chosen default | Why | When to switch |
|
| 67 |
+
| --- | --- | --- | --- |
|
| 68 |
+
| Embedded PDF text | PyMuPDF | Free, fast, and avoids OCR when the PDF already has text. | Do not switch unless extraction order is broken. |
|
| 69 |
+
| Scanned Arabic OCR | Maximum Arabic OCR mode | It compares QARI-OCR, Tawkeed, KATIB, Arabic-Qwen, Baseer, PaddleOCR-VL, PaddleOCR Arabic PP-OCRv5, EasyOCR Arabic, Surya, and Tesseract Arabic, then keeps the best cleaned Arabic text. | Use `OCR_ENGINE=paddleocr` only when speed matters more than comparison. |
|
| 70 |
+
| Single-engine OCR | PaddleOCR Arabic PP-OCRv5 | Official PaddleOCR docs list `arabic_PP-OCRv5_mobile_rec` for Arabic-script recognition, and it is lightweight enough for CPU workers. | Use EasyOCR or Tesseract when the per-book benchmark scores them better. |
|
| 71 |
+
| OCR comparison | `OCR_ENGINE=best` on short samples only | It compares available free engines and picks the best cleaned Arabic text. | Do not use for full 300+ page books unless runtime is acceptable. |
|
| 72 |
+
| Arabic-native optional OCR | QARI-OCR 0.4 | QARI-OCR 0.4 is fine-tuned for OCR on Islamic books and Arabic manuscripts, with Apache-2.0 model licensing. | Best Arabic-focused quality upgrade to benchmark on a GPU/strong worker. |
|
| 73 |
+
| Arabic-native external OCR | QARI-OCR 0.4 GGUF | GGUF packaging of QARI-OCR 0.4 for external runtime experiments. | Keep benchmark-only until the exact vision runner and same-page text quality beat the wired QARI sidecar. |
|
| 74 |
+
| Smaller Arabic-trained optional OCR | KATIB 0.8B | KATIB is fine-tuned for Arabic printed and handwritten OCR while being much smaller than QARI. | Try before QARI on smaller workers, then keep it only if it wins on the book sample. |
|
| 75 |
+
| Small Arabic-trained optional OCR | Arabic-Qwen3.5-OCR-v4 | Apache-2.0 0.9B Arabic OCR VLM for printed, handwritten, classical, and diacritic-heavy text. | Try alongside KATIB on short samples; keep only if it wins on the target pages. |
|
| 76 |
+
| Fresh Arabic OCR watchlist | aNS Qwen3-VL Arabic OCR v3 | Qwen3-VL-2B Arabic OCR fine-tune with a public Hugging Face card and no hosted inference provider. | Keep external until license fit, runtime, and same-page Arabic text quality beat QARI/KATIB/Arabic-Qwen/Baseer. |
|
| 77 |
+
| Structured Arabic OCR watchlist | Waraqon v3 Arabic OCR HTML Qari | Apache-2.0 Qari-family Arabic OCR model aimed at HTML/structured output. | Benchmark externally for complex pages; normalize markup to readable Arabic and promote only if it preserves book text better than the wired stack. |
|
| 78 |
+
| Latest general OCR watchlist | DeepSeek-OCR-2 | Official Apache-2.0 3B DeepSeek OCR successor with 2026 paper/model-card references and public document OCR benchmark results. | Benchmark externally only on strong-worker hardware; it is not Arabic-specific, so promote only if it beats QARI/KATIB/Baseer/Arabic-Qwen on the same book pages. |
|
| 79 |
+
| Latest Arabic DeepSeek OCR watchlist | Loay Arabic-OCR-DeepSeek-OCR-2 | Apache-2.0 Arabic fine-tune of DeepSeek-OCR-2 for Arabic OCR and layout analysis. | Benchmark externally on the same exported page images before any sidecar work; it is promising but needs strong-worker proof. |
|
| 80 |
+
| DeepSeek Arabic OCR watchlist | DeepSeek Arabic OCR v6 | Apache-2.0 Arabic-labeled DeepSeek-OCR fine-tune and newer than v4/v5. | Keep external until it proves better on the exported Arabic page images; the card has sparse Arabic-book evaluation detail and no hosted inference provider. |
|
| 81 |
+
| Handwritten OCR watchlist | Arabic-English handwritten OCR Qwen3-VL | Apache-2.0 Qwen3-VL-4B handwritten OCR model for Arabic and English. | Keep external for handwriting-heavy pages; the model card says research-oriented and it is not deployed by inference providers. |
|
| 82 |
+
| Handwritten OCR watchlist | Arabic-English handwritten OCR v3 | Apache-2.0 Qwen2.5-VL 3B-class handwritten/manuscript OCR model for Arabic and English. | Keep external for handwriting-heavy or manuscript pages; it is large and not deployed by inference providers. |
|
| 83 |
+
| Quantized handwritten OCR watchlist | Arabic handwritten OCR 4-bit Qwen2.5-VL | Apache-2.0 4-bit Arabic handwritten OCR checkpoint with about 2.44GB of model assets. | Benchmark externally when handwriting/manuscript pages matter and the full handwritten checkpoint is too heavy; promote only if same-page text beats the wired stack without hallucinations. |
|
| 84 |
+
| Manuscript line OCR watchlist | NAKBA Arabic manuscript line OCR baseline | Qwen3-VL-8B LoRA baseline for the NAKBA NLP 2026 Arabic manuscript understanding shared task, evaluated on line images. | Keep external and line-level only unless a preprocessing step crops pages into lines; confirm license fit before production. |
|
| 85 |
+
| Historical manuscript line OCR watchlist | HAFITH | Apache-2.0 Arabic-native 642M OCR model for historical manuscript line recognition, with 5.10% CER claims and Arabic-native tokenization. | Keep external and line-level only; it needs text-line segmentation before inference, so it is not a full-page PDF default. |
|
| 86 |
+
| RTL line OCR watchlist | Glimpse RTL OCR | Apache-2.0 Arabic/Persian RTL text-line OCR fine-tune with 6.97% CER claims on unseen RTL text lines. | Keep external and line-level only; it needs page images cropped into text lines before inference. |
|
| 87 |
+
| Full-page manuscript OCR watchlist | olmOCR Arabic LoRA v2 | Apache-2.0 LoRA for full-page Arabic manuscript OCR on an olmOCR 7B base. | Benchmark externally on a large worker; keep out of default because 7B base/runtime/license confirmation is needed. |
|
| 88 |
+
| GGUF OCR watchlist | Arabic OCR Qwen2.5-VL GGUF | QariOCR-v0.3-trained Arabic/English OCR fine-tune for scanned books, religious text, handwriting, and mixed-language documents on a Qwen2.5-VL 7B GGUF/Unsloth path. | Benchmark externally on a large worker; confirm license fit and keep only if it beats QARI/KATIB/Arabic-Qwen/Baseer on the same selected pages. |
|
| 89 |
+
| Line-level OCR watchlist | Qwen3-VL Persian/Arabic line OCR | Apache-2.0 Qwen3-VL 2B fine-tune for Persian/Arabic text-line OCR. | Keep external unless pages are cropped into text lines; the model card says it is not designed for full-page OCR. |
|
| 90 |
+
| Large Arabic OCR watchlist | Loay Arabic-OCR-Qwen2.5-VL-7B | Arabic OCR fine-tune of Qwen2.5-VL-7B for Arabic text from images. | Benchmark externally only on a large worker; 7B-class runtime is too heavy for the normal free default. |
|
| 91 |
+
| Arabic manuscript OCR watchlist | Ketaba-OCR LoRA | Apache-2.0 Arabic manuscript LoRA with strong benchmark claims, but it needs a separate base VLM plus adapter setup. | Benchmark externally on selected page images when the wired Arabic OCR stack fails. |
|
| 92 |
+
| Arabic manuscript OCR watchlist | Qari-OCR-LoRA | Apache-2.0 experimental QARI-family LoRA from the NakbaNLP 2026 Arabic manuscript OCR task; its model card says Ketaba was the primary winning submission. | Benchmark externally only as a secondary manuscript comparison after QARI 0.4, Ketaba, and the wired stack. |
|
| 93 |
+
| Arabic-first optional OCR | Tawkeed OCR | Apache-2.0 Arabic-first QARI v0.3 fork tuned for Arabic documents, handwriting, scene text, and edge-style deployment. | Wired as `OCR_ENGINE=tawkeed-ocr`; benchmark on short samples before full books, especially when QARI 4B is too heavy. |
|
| 94 |
+
| High-end optional OCR | PaddleOCR-VL-1.6 | The newest official PaddleOCR-VL document parser is an Apache-2.0 0.9B VLM path with state-of-the-art public document parsing claims and 109-language support. | Best general document-parser upgrade for a GPU/large-CPU worker; not the first default for free family hosting. |
|
| 95 |
+
| External document parser watchlist | oi-OCR | Apache-2.0 English/Arabic PDF document parser with April 2026 ParseBench claims for layout, tables, charts, and faithfulness. | Benchmark externally for structured PDFs; it is not Arabic-book-specific and has no inference provider deployment. |
|
| 96 |
+
| Multilingual document parser watchlist | NuExtract3 | Apache-2.0 4B document VLM for OCR, document-to-Markdown, tables, forms, invoices, contracts, multilingual documents, and multi-page PDF examples. | Benchmark externally for hard layouts; not the default Arabic-book OCR unless same-page scoring beats the Arabic-trained stack. |
|
| 97 |
+
| Large document parser watchlist | Qianfan-OCR | Apache-2.0 5B multilingual document-intelligence OCR/VLM with strong general document benchmark claims. | Benchmark externally only on a GPU/large worker; it is not Arabic-book-specific and is too heavy for the default free family worker. |
|
| 98 |
+
| Structured document parser watchlist | Chandra OCR 2 | 4B multilingual OCR/layout model for Markdown, HTML, JSON, tables, forms, and handwriting, with Arabic included among 90+ languages. | Benchmark externally for complex layouts only; modified OpenRAIL-M weights and GPU-heavy runtime keep it out of the default family-site stack. |
|
| 99 |
+
| Compact document parser watchlist | dots.ocr | MIT 1.7B multilingual document parser for layout detection, reading order, tables, formulas, and content recognition. | Benchmark externally for difficult layouts or mixed pages; not Arabic-book-specific, so promote only if same-page scoring beats the Arabic-trained stack. |
|
| 100 |
+
| Arabic book OCR-to-Markdown watchlist | Arabic Large Nougat | GPL-3.0 0.4B Arabic book-page OCR model for structured Markdown/text extraction. | Benchmark externally only; keep out of the default public worker because GPL licensing and model-card hallucination/context-length caveats need careful handling. |
|
| 101 |
+
| Classic Arabic OCR watchlist | DocTR Arabic FAST/PARSEQ | Arabic FAST detector with an Arabic PARSEQ recognizer path. | Benchmark externally when VLM OCR is too heavy; confirm recognizer licensing and compare text ordering before promotion. |
|
| 102 |
+
| Historical Arabic-script OCR watchlist | Kraken/eScriptorium Arabic script | Open-source ATR/OCR workflow for historical and non-Latin scripts, useful when pages look like older print, manuscripts, marginalia, or archival material. | Benchmark externally with an Arabic-script Kraken model or line-cropped eScriptorium workflow; keep out of the default worker until the selected model/license and page segmentation are proven on the book. |
|
| 103 |
+
| Service-only manuscript OCR signal | Kairawan/Qalamus manuscript OCR | Free 2026 Arabic and Islamic manuscript transcription service signal, useful evidence that Arabic manuscript OCR is improving. | Keep external only unless a reusable open engine, API terms, privacy story, and same-page scoring beat QARI/KATIB/Kraken/HAFITH on the selected sample. |
|
| 104 |
+
| Administrative OCR watchlist | GLM-OCR Arabic/French documents | GLM-OCR LoRA fine-tuned on Arabic/French scanned documents, forms, receipts, newspapers, and official documents. | Benchmark for structured or administrative PDFs, not ordinary book narration. |
|
| 105 |
+
| Sparse-card OCR watchlist | mimoha Arabic OCR | Apache-2.0 Arabic OCR model card with minimal public details. | Keep low priority until a clean local runner and stronger evaluation details are available. |
|
| 106 |
+
| Compact optional OCR VLM | Falcon-OCR | Apache-2.0 300M document OCR VLM from TII, with plain-text, formula, and table output modes. | Track for strong-worker benchmarks; do not default until it beats QARI/Paddle/Tesseract on Arabic book samples. |
|
| 107 |
+
| Heavy optional OCR | Surya / Chandra | Current Datalab document models support layout, reading order, and 90+ languages including Arabic, but weights/licensing and runtime are less simple than PaddleOCR. | Use only for difficult scans on a worker that can handle heavier VLM-style OCR. |
|
| 108 |
+
| Arabic-specific VLM OCR | Baseer OCR | Apache-2.0 Arabic document OCR fine-tuned from Qwen2-VL-2B, useful to track for difficult Arabic legal/complex layouts. | Promising, but 2B parameters and no default hosted inference provider make it optional research, not the default pipeline. |
|
| 109 |
+
| Arabic OCR VLM sidecar | Arabic-GLM-OCR-v2 | Recent Apache-2.0 Arabic OCR VLM with model-card claims for Arabic document extraction and noise reduction. | Wired as an optional sidecar; benchmark on the same 5-page sample before any full-book run. |
|
| 110 |
+
| Large Arabic OCR LoRA watchlist | DIMI Arabic OCR v2 | Apache-2.0 Arabic OCR LoRA based on Qwen2.5-VL-7B with model-card claims for improved diacritics handling and printed-document extraction. | Benchmark externally only on a large worker; 7B-class runtime is too heavy for the normal free default. |
|
| 111 |
+
| Darija OCR watchlist | AtlasOCR | First open-source Darija/Moroccan Arabic OCR model based on Qwen2.5-VL 3B. | Use only for Moroccan/Darija PDFs and confirm license terms before production use. |
|
| 112 |
+
| Legal/structured OCR watchlist | Arabic Legal Documents OCR 1.0 | Gemma-licensed Gemma-3-4B-IT fine-tune for low-quality scanned Arabic legal documents. | Benchmark externally only for legal or form-like PDFs; not a permissive default for ordinary book narration. |
|
| 113 |
+
| OCR correction research | Raqim post-OCR correction | 2026 open-access research on Arabic OCR correction using dictionary-based correction and LLMs. | Do not run automatically before TTS; correction can alter exact book/religious wording. |
|
| 114 |
+
| Arabic voice | SILMA TTS | Free/open Arabic-English TTS, Arabic Fusha/MSA support, normalization/tashkeel features, permissive model license. | Try Habibi MSA when you can afford a heavier optional sidecar; use eSpeak NG when speed matters more than naturalness. |
|
| 115 |
+
| Optional Arabic voice | Habibi-TTS MSA | Newer Arabic F5-TTS family project; the MSA specialized model is Apache-2.0. | Keep it optional because the unified and some dialect models have non-commercial limits. |
|
| 116 |
+
| Optional Arabic-only voice | 3arab-TTS 500M | Apache-2.0 Arabic-only 500M RF-DiT text-to-speech model, with a VoiceDesign variant updated in June 2026. | Benchmark externally on the same cleaned sample; too new to promote before long-form listening tests. |
|
| 117 |
+
| Optional high-speed Arabic voice | KaniTTS Arabic | Arabic-only 400M TTS model with high-speed/low-latency claims, but current Hugging Face metadata reports `other` for license. | Benchmark externally on the same cleaned sample; promote only if license fit is confirmed and it preserves words and pacing better than SILMA/Habibi. |
|
| 118 |
+
| Optional Gulf dialect voice | Emirati VITS Male | Apache-2.0 bilingual Emirati Arabic/English VITS voice. | Benchmark only when Gulf/Emirati pronunciation fits the PDF; not the default MSA audiobook voice. |
|
| 119 |
+
| Optional CPU-friendly voice | MOSS-TTS-Nano | Apache-2.0 multilingual 0.1B TTS with Arabic support, packaged CLI, and ONNX CPU path. | Benchmark externally with an Arabic reference voice before wiring it into the app; SILMA remains the stable default. |
|
| 120 |
+
| Optional CPU-friendly voice | Supertonic 3 | OpenRAIL multilingual 99M ONNX TTS with Arabic support and no GPU requirement. | Wired as `supertonic-ar` for local benchmarking; compare comfort against SILMA/Habibi before using it for full books. |
|
| 121 |
+
| Optional multilingual voice | OmniVoice | Apache-2.0 0.6B zero-shot TTS with 646-language support and Arabic included. | Fresh benchmark candidate; compare listenability against SILMA/Habibi before app wiring. |
|
| 122 |
+
| Optional Arabic adapter voice | OmniVoice Arabic LoRA | Apache-2.0 Arabic LoRA adapter for OmniVoice. | Try only after base OmniVoice works; compare against SILMA/Habibi on the same sample. |
|
| 123 |
+
| Optional Arabic dialect voice | Lahgtna OmniVoice v2 | New Arabic-dialect OmniVoice fine-tune with broad dialect tags and diacritics support. | Benchmark externally for dialectal content; confirm licensing before production and keep SILMA/Habibi first for MSA books. |
|
| 124 |
+
| Optional Arabic dialect voice | Lahgtna Chatterbox | MIT Arabic dialect TTS based on Chatterbox multilingual with several dialect tags. | Benchmark externally; the model card notes possible repetition, so long-form audiobook stability must be tested. |
|
| 125 |
+
| Optional Saudi dialect voice | NAMAA-Saudi-TTS | MIT Chatterbox Multilingual fine-tune for natural Saudi Arabic dialect speech. | Benchmark only for Saudi/Gulf dialect fit; its model card says it targets everyday Saudi speech rather than MSA books. |
|
| 126 |
+
| Optional Egyptian dialect voice | NAMAA-Egyptian-TTS | MIT Chatterbox Multilingual fine-tune for natural Egyptian Arabic dialect speech. | Benchmark only for Egyptian/dialectal PDFs; its model card targets everyday Egyptian speech rather than MSA books. |
|
| 127 |
+
| Optional Saudi dialect voice | Saudi Chatterbox fine-tune | Apache-2.0 Chatterbox Multilingual T3 fine-tune for Saudi Arabic speech. | Benchmark beside NAMAA-Saudi-TTS and Saudi Qwen3-TTS for Gulf material; not a default MSA book voice. |
|
| 128 |
+
| Optional Saudi dialect voice | Saudi TTS | Apache-2.0 Saudi Arabic TTS model. | Benchmark beside NAMAA-Saudi-TTS, Saudi Chatterbox fine-tune, and Saudi Qwen3-TTS for Gulf material; not a default MSA book voice. |
|
| 129 |
+
| Optional Egyptian dialect voice | Egyptian Arabic Chatterbox | Apache-2.0 Chatterbox fine-tune on 120 hours of Egyptian Arabic. | Benchmark only for Egyptian/dialectal PDFs; not the default MSA audiobook voice and GPU is recommended. |
|
| 130 |
+
| Optional Egyptian dialect voice | NileTTS-XTTS | Apache-2.0 XTTS v2 fine-tune from the 2026 NileTTS paper, trained on 38 hours of Egyptian Arabic. | Benchmark only for Egyptian/dialectal PDFs; it is not tuned for MSA book narration. |
|
| 131 |
+
| Optional Egyptian dialect voice | Arabic XTTS-v2 Egyptian fine-tune | MIT project on top of XTTS-v2/Coqui Public Model License base, with Egyptian speaker fine-tuning and same-text comparisons. | Benchmark only for Egyptian/dialectal PDFs after license fit is acceptable; not the permissive default MSA book voice. |
|
| 132 |
+
| Optional multilingual voice | Chatterbox-Multilingual | MIT multilingual TTS/voice-cloning project with Arabic listed among supported languages. | Benchmark externally against SILMA/Habibi on the exact same cleaned text before wiring. |
|
| 133 |
+
| Optional Arabic Chatterbox voice | Chatterbox Arabic fine-tune | MIT LoRA/adapter for Chatterbox-Multilingual that claims better Arabic pronunciation, diacritics, intonation, MSA, and dialect support. | Benchmark externally against SILMA/Habibi on the exact same cleaned text; prove long-form stability before wiring. |
|
| 134 |
+
| Optional CPU/ONNX multilingual voice | Chatterbox-Multilingual ONNX | MIT ONNX packaging for Chatterbox-Multilingual with Arabic listed among supported languages. | Benchmark externally on the same cleaned text to see whether CPU/ONNX runtime is practical for the free worker path. |
|
| 135 |
+
| Optional compact Arabic ONNX voice | tts-arabic-onnx | Arabic-only FastPitch/MixerTTS ONNX package with small models, vocoder choices, speaker IDs, pace, and vowelizer options. | Benchmark externally for CPU practicality, but confirm overall model/repo licensing before production use. |
|
| 136 |
+
| Optional Classical Arabic voice | Spark-TTS Arabic | Apache-2.0 Spark-TTS fine-tune on ClArTTS. | Benchmark externally only with diacritized input/reference audio; not a simple default. |
|
| 137 |
+
| Optional dialect voice | Sofelia-TTS | Apache-2.0 Palestinian Arabic TTS/voice-cloning model. | Use only for Palestinian/dialectal content benchmarks, not standard MSA books. |
|
| 138 |
+
| Optional strong-worker voice | VoxCPM2 | Apache-2.0 multilingual TTS with Arabic support, 2B parameters, 48 kHz output, and voice design/cloning. | Benchmark only on GPU/large worker; SILMA remains the practical Arabic-focused default. |
|
| 139 |
+
| Personal/non-commercial strong-worker voice | Voxtral TTS | Mistral open-weight 4B TTS with Arabic among 9 listed languages and strong latency/quality claims. | Do not default for the public/family site because the model card lists `cc-by-nc-4.0` and 16GB+ GPU guidance. |
|
| 140 |
+
| Personal/non-commercial voice caution | Arabic-F5-TTS-v2 | MSA F5-TTS fine-tune with strong Arabic sample claims. | Do not use as the default public free app voice because it is non-commercial and requires fully diacritized Arabic. |
|
| 141 |
+
| Do-not-promote-yet TTS | Qwen3-TTS | Apache-2.0 and high quality, but official released model cards list 10 languages and do not include Arabic. | Track future Arabic checkpoints/fine-tunes; do not add to the Arabic reader dropdown yet. |
|
| 142 |
+
| Dialect-only Qwen3-TTS benchmark | Egyptian Arabic Qwen3-TTS | Apache-2.0 1.7B Qwen3-TTS fine-tune for Egyptian Arabic with about 25 hours of clean Egyptian speech. | Benchmark only for Egyptian/dialectal content; it is too large and dialect-specific for the normal MSA book default. |
|
| 143 |
+
| Dialect-only Qwen3-TTS benchmark | Saudi Arabic Qwen3-TTS | Apache-2.0 1.7B Qwen3-TTS fine-tune for Saudi/KSA Arabic speech. | Benchmark only when Saudi/Gulf pronunciation fits the PDF; keep SILMA/Habibi first for MSA books. |
|
| 144 |
+
| Dialect-only Qwen3.5-TTS benchmark | Emirati Qwen3.5-TTS | Apache-2.0 Emirati Arabic Qwen3-TTS-family fine-tune. | Benchmark beside Emirati VITS Male for Gulf/Emirati material; do not make it the default MSA audiobook voice. |
|
| 145 |
+
| Audio format | MP3 on workers, WAV locally | MP3 is much smaller for downloads when `ffmpeg` is available. | WAV is fine for short local tests. |
|
| 146 |
+
|
| 147 |
+
## Why Arabic Specialist OCR Is The Default
|
| 148 |
+
|
| 149 |
+
On the one-page sample from the user's scanned Arabic book:
|
| 150 |
+
|
| 151 |
+
```text
|
| 152 |
+
EasyOCR: 4,207 characters, 743 Arabic words
|
| 153 |
+
PaddleOCR: 2,546 characters, 495 Arabic words
|
| 154 |
+
Tesseract: 4,344 characters, 767 Arabic words
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Tesseract was fastest and extracted a high volume of text, but its preview included more page-layout noise. PaddleOCR was faster than EasyOCR and uses a current Arabic PP-OCRv5 recognition model, but it dropped and garbled more text on this specific book page. EasyOCR had the cleanest main-text preview on that sample, so it remains an important alternate. Arabic specialist mode is now the default because it compares Arabic-configured recognizers and picks the cleanest text for the book page. Plain PaddleOCR remains available when speed matters more than automatic comparison.
|
| 158 |
+
|
| 159 |
+
The important rule is: default to maximum Arabic-trained OCR for a new scanned book, but benchmark a representative sample before processing a long book. A single OCR engine is not consistently best across Arabic fonts, scan quality, page skew, and book layouts. QARI is the best Arabic-book model to try on a strong worker; KATIB is the smaller Arabic-trained model to try when QARI is too heavy.
|
| 160 |
+
|
| 161 |
+
For short samples, the app supports `OCR_ENGINE=best`. That mode runs the available free local OCR engines, tries multiple Tesseract page segmentation modes, scores the cleaned Arabic text by Arabic-word volume, common-word hits, placeholder characters, repeated layout lines, tiny-line noise, digit-heavy lines, symbol noise, and Latin noise, then uses the best-scoring output. It is not the default for full books because it can multiply OCR time on 100 MB+ PDFs.
|
| 162 |
+
|
| 163 |
+
The default best-mode settings are:
|
| 164 |
+
|
| 165 |
+
```text
|
| 166 |
+
BEST_OCR_RENDER_ZOOMS=1.5
|
| 167 |
+
BEST_TESSERACT_PSMS=4
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
The render-zoom list is deliberately conservative. On this Windows test machine, adding `2.25` made the corrected one-page best-mode benchmark too slow for a routine website flow. If you still want a high-resolution bakeoff for a difficult page, temporarily set:
|
| 171 |
+
|
| 172 |
+
```text
|
| 173 |
+
BEST_OCR_RENDER_ZOOMS=1.5,2.25
|
| 174 |
+
BEST_TESSERACT_PSMS=4,6
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
Use that only on quick tests or selected sample pages first. If the higher-resolution candidate clearly wins on a book, set the matching single-engine render zoom for the full run.
|
| 178 |
+
|
| 179 |
+
The app also cleans the selected text before speech. This matters because the most accurate-looking OCR output can still include page numbers, repeated book headers, digit-heavy layout fragments, and symbol debris that would sound terrible if read aloud. The TTS-specific cleanup normalizes Arabic-Indic digits, removes tatweel and decorative Quran annotation marks, expands common Arabic ligatures such as `ﷺ`, and fixes punctuation spacing while preserving useful Arabic diacritics.
|
| 180 |
+
|
| 181 |
+
The dry-run and benchmark tools report a text quality status before audio is generated. `quality=good` means the cleaned Arabic text is ready to send to TTS, `quality=warning` means the text is usable but still has minor artifacts, and `quality=poor` means OCR should be fixed before creating audio.
|
| 182 |
+
|
| 183 |
+
The web job path uses the same gate in local, worker, and browser-cloud modes. Poor text does not proceed to speech generation, which protects free-worker CPU time and hosted TTS calls from audio that would not be listenable.
|
| 184 |
+
|
| 185 |
+
For new books, the website defaults to a first-5-pages quick test. That keeps large scanned PDFs from tying up the free worker before OCR quality and pronunciation are known.
|
| 186 |
+
|
| 187 |
+
When the opening pages are not representative, use `scripts/select_test_pages.py` to build a small sample from the most text-dense pages. It scores embedded Arabic text and low-resolution ink density, so it also works as a rough selector for scanned books.
|
| 188 |
+
|
| 189 |
+
Voice quality should be tested the same way OCR quality is tested. Run `scripts/benchmark_voices.py --write-report outputs/voice-benchmark-report.md` to synthesize the same Arabic sample with SILMA, optional Habibi MSA, optional Supertonic 3, and eSpeak NG. For external candidates like 3arab-TTS 500M, KaniTTS Arabic, Emirati VITS Male, MOSS-TTS-Nano, OmniVoice/Arabic LoRA, Lahgtna OmniVoice v2, Lahgtna Chatterbox, NAMAA-Saudi-TTS, NAMAA-Egyptian-TTS, Saudi Chatterbox fine-tune, Chatterbox Arabic fine-tune, Saudi TTS, Egyptian Arabic Chatterbox, NileTTS-XTTS, Arabic XTTS-v2 Egyptian fine-tune, Chatterbox-Multilingual, Chatterbox-Multilingual ONNX, tts-arabic-onnx, Spark-TTS Arabic, Sofelia-TTS, Egyptian Arabic Qwen3-TTS, Saudi Arabic Qwen3-TTS, Emirati Qwen3.5-TTS, VoxCPM2, and personal/non-commercial Voxtral TTS, run `scripts/export_tts_sample.py C:\path\to\arabic-book.pdf --env-file outputs\recommended-ocr.env --out-dir outputs\external-tts-sample` and use the generated `outputs\external-tts-sample\arabic-tts-sample.txt` for every model. For external OCR candidates like QARI-OCR 0.4 GGUF, Loay Arabic-OCR-DeepSeek-OCR-2, DeepSeek-OCR-2, DeepSeek Arabic OCR v6, aNS Qwen3-VL Arabic OCR v3, Waraqon v3 Arabic OCR HTML Qari, DIMI Arabic OCR v2, Loay Arabic-OCR-Qwen2.5-VL-7B, Arabic-English handwritten OCR Qwen3-VL, Arabic-English handwritten OCR v3, Arabic handwritten OCR 4-bit Qwen2.5-VL, NAKBA Arabic manuscript line OCR baseline, HAFITH, Glimpse RTL OCR, olmOCR Arabic LoRA v2, Arabic OCR Qwen2.5-VL GGUF, Qwen3-VL Persian/Arabic line OCR, AtlasOCR, Ketaba-OCR, Qari-OCR-LoRA, DocTR Arabic FAST/PARSEQ, Kraken/eScriptorium Arabic script, Kairawan/Qalamus manuscript OCR, GLM-OCR Arabic/French documents, mimoha Arabic OCR, oi-OCR, Qianfan-OCR, and Falcon-OCR, run `scripts/export_ocr_sample_images.py C:\path\to\arabic-book.pdf --out-dir outputs\external-ocr-sample` and use the generated `page-*.png` files for every model. Then run `scripts\score_external_ocr.py` on the resulting text files so the external text is judged with the same Arabic speech-readiness metrics as the app. Tawkeed, Arabic-GLM-OCR-v2, Arabic-Qwen3.5-OCR-v4, and Baseer are already wired as optional sidecars, but you can still score external output if you have not installed the sidecars locally. The reports give timing and file paths, but the final choice should be made by listening to the audio samples and reading the OCR text because Arabic pronunciation, pacing, reading order, and comfort matter more than raw speed.
|
| 190 |
+
|
| 191 |
+
On the one-page scanned Arabic book sample, the corrected `OCR_ENGINE=best` benchmark selected Tesseract at 2x render scale with page segmentation mode 4:
|
| 192 |
+
|
| 193 |
+
```text
|
| 194 |
+
easyocr@1.5x=3668.4
|
| 195 |
+
paddleocr@1.5x=2479.5
|
| 196 |
+
tesseract@2x-psm4=3736.0
|
| 197 |
+
tesseract@2x-psm6=3255.5
|
| 198 |
+
selected=tesseract@2x-psm4
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
That comparison took 260.83 seconds for one page on this Windows machine. The app now keeps Arabic specialist mode as the normal quality default because it catches cases where a single Arabic OCR engine fragments text. Use plain `OCR_ENGINE=paddleocr` only when you need a faster single-engine full-book run.
|
| 202 |
+
|
| 203 |
+
`scripts/benchmark_ocr.py` now prints a concrete full-book environment recommendation from the winning sample extraction, such as `OCR_ENGINE=tesseract OCR_RENDER_ZOOM=2 TESSERACT_PSM=4`. This keeps the expensive comparison step separate from the long production run.
|
| 204 |
+
|
| 205 |
+
## Research Notes
|
| 206 |
+
|
| 207 |
+
- EasyOCR supports Arabic and many other scripts, and remains easy to run locally on CPU.
|
| 208 |
+
- PaddleOCR 3.x includes `arabic_PP-OCRv5_mobile_rec`, an Arabic-script recognition model listed for Arabic, Persian, Uyghur, Urdu, Pashto, Kurdish, Sindhi, Balochi, and English. The same documentation reports 81.27 accuracy on its Arabic dataset with 22.83 improvement over the previous generation.
|
| 209 |
+
- The PP-OCRv5 paper describes a 5M-parameter specialized OCR system intended to stay lightweight while competing with much larger OCR models on standard benchmarks. That supports using it as the practical free default rather than jumping straight to a large VLM.
|
| 210 |
+
- QARI-OCR 0.4 is an Apache-2.0 Arabic OCR VLM on Hugging Face, built on Qwen3-VL-4B and fine-tuned on Arabic book/manuscript OCR pairs. This is now the most directly relevant Arabic-specific OCR upgrade in the repo. The older QARI-OCR v0.3 2B model remains configurable with `QARI_OCR_MODEL` for smaller workers.
|
| 211 |
+
- QARI-OCR 0.4 GGUF is an external benchmark path for testing QARI-style Arabic book OCR with a GGUF runtime. Keep it benchmark-only until the exact vision runner, image-prompt format, speed, and text quality beat the wired QARI sidecar on the same selected pages.
|
| 212 |
+
- KATIB 0.8B is an Apache-2.0 Arabic OCR VLM fine-tuned for Arabic printed and handwritten text. It is wired as the smaller Arabic-trained sidecar to try before the heavier QARI path.
|
| 213 |
+
- Ketaba-OCR LoRA is an Apache-2.0 Arabic manuscript OCR benchmark candidate. Keep it external for now because it requires a Qwen2.5-VL base model plus adapter setup rather than a simple sidecar.
|
| 214 |
+
- Qari-OCR-LoRA is an Apache-2.0 QARI-family manuscript LoRA from the NakbaNLP 2026 Arabic manuscript OCR task. Keep it secondary to Ketaba because its own model card describes Ketaba as the primary winning model.
|
| 215 |
+
- Tawkeed OCR is an Apache-2.0 Arabic-first OCR model forked from QARI-OCR v0.3 and fine-tuned for Arabic documents, handwriting, and scene text. It is wired as `OCR_ENGINE=tawkeed-ocr`, and is most useful when QARI-OCR 0.4 is too heavy or when edge-style Arabic OCR matters.
|
| 216 |
+
- PaddleOCR-VL-1.6 now has a June 2026 paper signal in addition to the model card and official PaddleOCR docs. It is described as a 0.9B document parsing VLM with 96.3% OmniDocBench v1.6 accuracy, 109-language support, and state-of-the-art results across text, formulas, and tables. This is the most important general free/open OCR direction to watch for a stronger hosted worker.
|
| 217 |
+
- oi-OCR is an Apache-2.0 Arabic-capable PDF/document parser with April 2026 ParseBench claims. Keep it external and score it on the same exported page images because structured Markdown/table extraction is different from clean long-form Arabic book transcription.
|
| 218 |
+
- NuExtract3 is an Apache-2.0 4B multilingual document understanding model for OCR, document-to-Markdown, structured extraction, and multi-page PDF workflows. It is useful for hard layouts, forms, tables, and contracts, but it is not Arabic-book-specific and should stay external until same-page scoring proves it beats QARI/KATIB/Arabic-Qwen/Baseer.
|
| 219 |
+
- Qianfan-OCR is an Apache-2.0 5B multilingual document-intelligence OCR/VLM. It has strong general document benchmark claims and public model-card usage examples, but it is not Arabic-book-specific and needs a GPU/large-worker external benchmark before it should influence this app.
|
| 220 |
+
- Chandra OCR 2 is a 4B multilingual structured-document OCR model with Arabic support, Markdown/HTML/JSON output, and strong layout/table/form claims. Its code is Apache-2.0, but the weights are modified OpenRAIL-M, so it stays in the external benchmark lane for personal/family testing rather than the default hosted stack.
|
| 221 |
+
- dots.ocr is an MIT 1.7B multilingual document parser that combines layout detection and content recognition, including reading-order, table, and formula handling. It is promising for complex Arabic layouts, but not Arabic-book-specific, so it stays external until same-page scoring beats the Arabic-trained stack.
|
| 222 |
+
- DocTR Arabic FAST/PARSEQ is a classic OCR alternative: the FAST detector card is Apache-2.0 and designed to pair with an Arabic PARSEQ recognizer. Keep it external until the recognizer license and same-page Arabic book accuracy are confirmed.
|
| 223 |
+
- Kairawan/Qalamus manuscript OCR is a free 2026 web-service signal for Arabic manuscript transcription, not a reusable worker dependency yet. Keep it external unless an open package/API terms/privacy path becomes clear and same-page scoring beats the wired Arabic OCR stack.
|
| 224 |
+
- GLM-OCR Arabic/French documents is a recent GLM-OCR LoRA fine-tune for scanned Arabic/French administrative documents. It belongs in the form/newspaper/official-document benchmark lane, not the default book OCR lane.
|
| 225 |
+
- mimoha Arabic OCR is an Apache-2.0 sparse-card Arabic OCR watchlist item. Keep it low priority unless a clean local runner and stronger evaluation details appear.
|
| 226 |
+
- Surya is promising and supports OCR, layout analysis, reading order, and table recognition in 90+ languages. Current Surya 2 is a 650M-parameter document OCR model that can use `llama.cpp` or `vllm`, making it a better future upgrade path than a first default for a simple family website.
|
| 227 |
+
- Surya's code is Apache-2.0, but its model weights use a modified AI Pubs OpenRAIL-M license that is free for research, personal use, and startups under the stated revenue/funding threshold. That is fine for a family/personal project but less simple than PaddleOCR's default path.
|
| 228 |
+
- SILMA TTS is a 150M-parameter Arabic/English TTS model with Arabic Fusha/MSA support, diacritization, text normalization, accepts text with or without tashkeel, MIT code, and Apache-2.0 model weights. SILMA's Hugging Face launch article also calls out Arabic text handling, chunking, normalization, robustness, and audio-quality improvements. It is currently the best free local Arabic voice in this project because it is Arabic-focused, permissively licensed, and practical to run as a worker sidecar.
|
| 229 |
+
- SILMA includes Arabic diacritization and text normalization features. This project exposes them through `SILMA_ENABLE_NORMALIZER`, `SILMA_FORCE_TASHKEEL`, and `SILMA_NORMALIZE_NUMBERS`; they are off by default in the Windows sidecar and Docker worker because those setups use dependency stubs. On Linux, set `SILMA_FULL_NORMALIZER_DEPS=1` during setup/build if you want to install and test the full packages.
|
| 230 |
+
- Mishkala Tashkeel is a lightweight Apache-2.0 Arabic diacritization model to test as a pronunciation preprocessor. Keep it external until listening tests prove it improves the exact book sample, because wrong automatic harakat can make Arabic narration sound worse or subtly change perceived meaning.
|
| 231 |
+
- Tashkeel-350M is a larger Apache-2.0 Arabic diacritization benchmark. Compare it beside Mishkala on the same cleaned sample, because a larger preprocessor can improve pronunciation for one book and still make another book less comfortable or less faithful.
|
| 232 |
+
- Mushkil is an Apache-2.0 AraT5V2 Arabic diacritization benchmark. Compare it beside Mishkala and Tashkeel-350M on the same cleaned sample, and keep it external unless it improves pronunciation while preserving meaning and long-listen comfort.
|
| 233 |
+
- Thaka KSAA-2026 speech diacritization is a late-May 2026 shared-task-winning research signal for Arabic diacritization using speech plus text. Track it for future permissive code or checkpoints, but do not add it to the app yet because the current public evidence is a paper/benchmark result rather than a deployable model.
|
| 234 |
+
- Habibi-TTS is a 2026 open-source Arabic speech synthesis project built around F5-TTS with a multi-dialect benchmark. Its specialized MSA model is Apache-2.0, but its unified, Saudi, and UAE models are CC-BY-NC-SA-4.0, so the app exposes `habibi-msa` as an optional MSA voice rather than making Habibi the default.
|
| 235 |
+
- 3arab-TTS 500M is a very new Apache-2.0 Arabic-only 500M TTS model, with a VoiceDesign variant updated in June 2026. It is worth a same-sample listening benchmark, but it should not replace SILMA/Habibi until long-form stability, pacing, and setup complexity are proven.
|
| 236 |
+
- KaniTTS Arabic is a recent Arabic-only 400M TTS model with high-speed claims. Test it when generation speed matters, but confirm license fit and compare skipped words, pronunciation, and long-passage comfort against SILMA/Habibi before promoting it.
|
| 237 |
+
- Emirati VITS Male is an Apache-2.0 bilingual Emirati Arabic/English voice. It belongs in the dialect benchmark lane, not the default MSA audiobook lane, unless the target PDF benefits from Gulf pronunciation.
|
| 238 |
+
- MOSS-TTS-Nano is an Apache-2.0 0.1B multilingual TTS model with Arabic among 20 supported languages. Its ONNX CPU path and packaged CLI make it a serious candidate for a free worker, but it needs an Arabic reference-voice benchmark before it should replace SILMA in the father-friendly UI.
|
| 239 |
+
- Supertonic 3 is an OpenRAIL 99M multilingual TTS model with Arabic among 31 supported languages and a local ONNX CPU path. It is wired as optional `supertonic-ar`; use it as a speed/practicality benchmark, not as the Arabic-first default until listening tests beat SILMA/Habibi on the target book.
|
| 240 |
+
- VoxCPM2 is an Apache-2.0 2B multilingual TTS model with Arabic listed among 30 supported languages, 48 kHz output, voice design/cloning, and roughly 8 GB VRAM guidance on its model card. It is now worth tracking as a strong-worker voice benchmark candidate, but SILMA remains the practical default because it is Arabic-focused and lighter.
|
| 241 |
+
- Voxtral TTS is Mistral's open-weight 4B TTS model with Arabic among 9 listed languages and strong latency/quality claims. Keep it external and personal/non-commercial because the model card lists `cc-by-nc-4.0` and 16GB+ GPU guidance.
|
| 242 |
+
- Falcon-OCR is an Apache-2.0 300M early-fusion document OCR VLM from TII. It is promising because it is much smaller than 0.9B+ document VLMs, but it is general document OCR rather than Arabic-book-specific, so it should enter the benchmark watchlist before it enters the default pipeline.
|
| 243 |
+
- Baseer OCR V1.0 is an Apache-2.0 Arabic OCR VLM fine-tuned from Qwen2-VL-2B for Arabic legal/complex documents, including multi-column layouts, stamps, tables, handwritten, and printed text. It is wired as an optional sidecar; benchmark it on selected pages before full-book use.
|
| 244 |
+
- Arabic-GLM-OCR-v2 is a newer Apache-2.0 Arabic OCR VLM sidecar. Its model card reports strong Arabic document extraction and noise reduction, but it still needs independent scoring against QARI/KATIB/PaddleOCR/Tesseract on the selected book images before any full-book run.
|
| 245 |
+
- DIMI Arabic OCR v2 is a newer Apache-2.0 Arabic OCR LoRA fine-tuned from Qwen2.5-VL-7B. Keep it external unless the smaller Arabic OCR sidecars fail and a strong worker can handle 7B-class LoRA inference.
|
| 246 |
+
- Raqim-style OCR correction is research-only for now. Dictionary/LLM correction can improve OCR metrics, but it can also change source wording; keep exact extracted text as the default for religious and book audio.
|
| 247 |
+
- Arabic-Qwen3.5-OCR-v4 is a 0.9B Apache-2.0 Arabic OCR VLM now wired as an optional sidecar. Its model card claims printed, handwritten, classical, and diacritic-heavy Arabic support, so benchmark it against KATIB/QARI/PaddleOCR/Tesseract before full-book use.
|
| 248 |
+
- DeepSeek-OCR-2 is the official Apache-2.0 3B DeepSeek OCR successor with 2026 model-card/paper references and public document OCR benchmark results. It is not Arabic-specific, so keep it external and compare only on the exported Arabic book page images.
|
| 249 |
+
- Loay Arabic-OCR-DeepSeek-OCR-2 is an Apache-2.0 Arabic fine-tune of DeepSeek-OCR-2 for high-precision Arabic OCR and layout analysis. It is promising, but still belongs in the external benchmark lane because it needs same-page scoring against QARI/KATIB/Arabic-Qwen/Baseer and a strong-worker runtime check.
|
| 250 |
+
- DeepSeek Arabic OCR v6 is a newer Apache-2.0 Arabic-labeled DeepSeek-OCR fine-tune. It is promising enough to benchmark, but not enough to default because the model card has sparse Arabic-book evidence and no hosted inference provider.
|
| 251 |
+
- Arabic-English handwritten OCR Qwen3-VL is an Apache-2.0 Qwen3-VL-4B watchlist model for Arabic/English handwritten OCR. Keep it external for handwriting-heavy pages because the model card presents it as research-oriented and not inference-provider deployed.
|
| 252 |
+
- Arabic-English handwritten OCR v3 is an Apache-2.0 Qwen2.5-VL 3B-class watchlist model for Arabic/English handwriting and manuscripts. Keep it external for handwriting-heavy pages because it is large and not inference-provider deployed.
|
| 253 |
+
- Arabic handwritten OCR 4-bit Qwen2.5-VL is an Apache-2.0 4-bit handwriting/manuscript checkpoint with about 2.44GB of model assets. It is a better external benchmark than the full handwritten checkpoint when the worker is memory-constrained, but it still needs same-page scoring before any wiring.
|
| 254 |
+
- NAKBA Arabic manuscript line OCR baseline is a Qwen3-VL-8B LoRA baseline for the NAKBA NLP 2026 Arabic manuscript understanding shared task. Keep it line-level and external unless page images are first cropped into manuscript text lines, and confirm license fit before production.
|
| 255 |
+
- HAFITH is an Apache-2.0 historical Arabic manuscript recognition model with Arabic-native tokenization and strong line-level CER claims. Glimpse RTL OCR is a separate Apache-2.0 Arabic/Persian text-line OCR benchmark with 6.97% CER claims. Keep both external because they require pre-segmented text lines rather than full-page PDFs.
|
| 256 |
+
- olmOCR Arabic LoRA v2 fills the full-page manuscript lane when line cropping is impractical. Benchmark it externally against Ketaba/QARI and line-cropped options because it depends on a 7B olmOCR base model.
|
| 257 |
+
- Arabic Large Nougat is a 0.4B Arabic book-page OCR-to-Markdown benchmark. It is directly relevant to printed Arabic books, but keep it external because it is GPL-3.0 and the model card warns about hallucination and context-length limits.
|
| 258 |
+
- Arabic OCR Qwen2.5-VL GGUF is a QariOCR-v0.3-trained Arabic/English OCR fine-tune on a Qwen2.5-VL 7B GGUF/Unsloth path. It is relevant for scanned books, religious text, handwriting, and mixed Arabic-English documents, but it is large and needs license confirmation and same-page scoring before wiring.
|
| 259 |
+
- Qwen3-VL Persian/Arabic line OCR is an Apache-2.0 Qwen3-VL 2B watchlist model for cropped Persian/Arabic text-line OCR. Keep it external unless a layout step crops pages into lines because the model card says it is not designed for full-page OCR.
|
| 260 |
+
- Loay Arabic-OCR-Qwen2.5-VL-7B is a larger Arabic OCR VLM fine-tuned from Qwen2.5-VL-7B for Arabic text in images. Keep it external unless the smaller Arabic OCR sidecars fail and a strong worker can handle 7B-class inference.
|
| 261 |
+
- AtlasOCR is a Darija/Moroccan Arabic OCR model based on Qwen2.5-VL 3B. It is not the default for standard Arabic books, but it is worth tracking for Moroccan/Darija PDFs after license terms are confirmed.
|
| 262 |
+
- Arabic Legal Documents OCR 1.0 is a Gemma-licensed Gemma-3-4B-IT fine-tune for low-quality scanned Arabic legal documents. It may help legal or form-like PDFs, but it is domain-specific and should stay outside the default book-to-audio stack.
|
| 263 |
+
- Facebook MMS TTS supports Arabic and is useful as a hosted fallback in Vercel mode, but the broader MMS TTS license is CC-BY-NC-4.0, so it is not the best permissive default for the local/worker stack.
|
| 264 |
+
|
| 265 |
+
## Voice Selection Process
|
| 266 |
+
|
| 267 |
+
Use SILMA as the first local voice to test. It is the recommended baseline because it is Arabic-focused, lightweight compared with larger multilingual TTS systems, and Apache-2.0.
|
| 268 |
+
|
| 269 |
+
Use Habibi MSA as the optional second test when the sidecar is installed. It may sound better for some Modern Standard Arabic passages, but keep it optional because only the specialized models are Apache-2.0.
|
| 270 |
+
|
| 271 |
+
Use Mishkala Tashkeel, Tashkeel-350M, and Mushkil as external pronunciation-preprocessor benchmarks, not as automatic website defaults. Diacritize the same cleaned text sample, then listen-test plain, Mishkala, Tashkeel-350M, and Mushkil versions with SILMA/Habibi/Supertonic/eSpeak. Keep a preprocessor only if it improves pronunciation without adding distracting or wrong harakat and `scripts\score_tts_preprocessor.py` says the meaning-preservation score is high enough.
|
| 272 |
+
|
| 273 |
+
Track Thaka KSAA-2026 speech diacritization as a research signal, not a website dependency. The paper shows a winning CATT-Whisper ensemble for speech dictation diacritization, which is useful for understanding where Arabic pronunciation preprocessing is going, but it should not affect the production stack until a permissive, practical implementation can be tested on the same cleaned book sample.
|
| 274 |
+
|
| 275 |
+
Use 3arab-TTS 500M as a new Arabic-only external benchmark. It is permissively licensed and much smaller than VoxCPM2/Voxtral, but it is new enough that long-form stability and audiobook comfort need to be proven before it becomes a normal app voice.
|
| 276 |
+
|
| 277 |
+
Use KaniTTS Arabic as a speed-focused Arabic-only external benchmark. It is smaller than large multilingual voices, but its current metadata reports `other`, so verify license fit, word preservation, and pacing on the exact cleaned book text before wiring it into the app.
|
| 278 |
+
|
| 279 |
+
Use Emirati VITS Male only as a dialect-specific voice benchmark. It may be a nice match for Gulf/Emirati Arabic, but for MSA books SILMA/Habibi remain the better first tests.
|
| 280 |
+
|
| 281 |
+
Use MOSS-TTS-Nano as a future CPU-friendly benchmark. It is permissively licensed, supports Arabic, and has an ONNX CPU path, but it needs a clean Arabic reference/prompt audio workflow before it should appear as a normal app voice.
|
| 282 |
+
|
| 283 |
+
Use Supertonic 3 as the wired CPU-friendly comparison voice. It supports Arabic with a compact ONNX model and can be installed with `scripts/setup_supertonic.ps1`, but it is multilingual rather than Arabic-specialized, so listen against SILMA/Habibi on the same passage before choosing it for a full book.
|
| 284 |
+
|
| 285 |
+
Do not add Kyutai Pocket TTS to the Arabic voice picker yet. The current official Kyutai page makes it attractive for CPU real-time TTS, but the listed Pocket TTS languages are English, French, German, Spanish, Portuguese, and Italian, not Arabic. Revisit it only if Kyutai publishes Arabic support.
|
| 286 |
+
|
| 287 |
+
Use OmniVoice as the newest multilingual Arabic benchmark candidate. It is Apache-2.0, has Arabic in its broad language list, and is smaller than VoxCPM2, but it still needs side-by-side listening against SILMA/Habibi on the same cleaned Arabic text.
|
| 288 |
+
|
| 289 |
+
Use OmniVoice Arabic LoRA only after base OmniVoice is working. It is an Apache-2.0 Arabic adapter, but it adds another moving part and still needs the same listening and runtime test.
|
| 290 |
+
|
| 291 |
+
Use Lahgtna OmniVoice v2 only for dialect experiments. It is Arabic-focused and newer than the Chatterbox dialect entry, but the model card does not declare a license, so it should stay external until licensing and long-form stability are proven.
|
| 292 |
+
|
| 293 |
+
Use Lahgtna Chatterbox as a dialect benchmark, not the default audiobook voice. It is MIT and Arabic-focused across dialects, but long-form repetition/stability needs to be tested on the same cleaned sample.
|
| 294 |
+
|
| 295 |
+
Use Egyptian Arabic Chatterbox only for Egyptian/dialectal Arabic experiments. It is Apache-2.0 and trained on Egyptian Arabic, but it is a Chatterbox fine-tune that recommends GPU for real-time inference and may not fit MSA books.
|
| 296 |
+
|
| 297 |
+
Use NileTTS-XTTS only for Egyptian/dialectal Arabic experiments. The 2026 NileTTS paper and model card report improved Egyptian Arabic intelligibility versus XTTS v2, but it is trained on Egyptian Arabic and should not replace SILMA or Habibi MSA for standard Arabic books.
|
| 298 |
+
|
| 299 |
+
Use Arabic XTTS-v2 Egyptian fine-tune only as a dialectal listening benchmark after license review. Its project is MIT, but it is built on XTTS-v2 with the Coqui Public Model License, so keep it outside the default public/family deployment path unless that license and voice-cloning runtime fit the exact use case.
|
| 300 |
+
|
| 301 |
+
Use Chatterbox-Multilingual as a broader MIT Arabic-capable benchmark. It supports Arabic via `language_id="ar"` and can use a reference voice, but long-form book pacing and watermarking should be judged before it becomes a website voice.
|
| 302 |
+
|
| 303 |
+
Use Chatterbox Arabic fine-tune as the first Arabic-focused Chatterbox adapter to compare against SILMA/Habibi for MSA books. It is MIT and claims better Arabic pronunciation, diacritics, intonation, MSA, and dialect support, but it needs same-text listening tests and long-form stability proof before it becomes a normal app voice.
|
| 304 |
+
|
| 305 |
+
Use Chatterbox-Multilingual ONNX as the CPU/ONNX variant to benchmark when the free worker path needs a more portable runtime. It is MIT and Arabic-capable, but repetition, pacing, and runtime still need same-sample listening tests.
|
| 306 |
+
|
| 307 |
+
Use tts-arabic-onnx as a compact Arabic-only ONNX benchmark when CPU runtime is the bottleneck. It exposes smaller FastPitch/MixerTTS and vocoder combinations plus vowelizer options, but keep it external until the overall model/repo licensing is confirmed.
|
| 308 |
+
|
| 309 |
+
Use Spark-TTS Arabic as an external Classical/MSA benchmark only when you can provide diacritized input and reference audio through the Spark-TTS repo. Its setup is too involved for the father-friendly default.
|
| 310 |
+
|
| 311 |
+
Use Sofelia-TTS only for Palestinian/dialectal Arabic experiments. It is permissively licensed, but not a general MSA audiobook default.
|
| 312 |
+
|
| 313 |
+
Use Egyptian Arabic Qwen3-TTS only for Egyptian/dialectal Arabic experiments. It is permissively licensed and Arabic-capable, but it is a 1.7B Qwen3-TTS fine-tune, not hosted by an inference provider, and not the right default for MSA book narration.
|
| 314 |
+
|
| 315 |
+
Use NAMAA-Saudi-TTS, Saudi Chatterbox fine-tune, Saudi TTS, Saudi Arabic Qwen3-TTS, and Emirati Qwen3.5-TTS only for Gulf dialect experiments. NAMAA-Saudi-TTS is MIT and Chatterbox-based, Saudi Chatterbox fine-tune and Saudi TTS are Apache-2.0, while the Qwen voices are Apache-2.0 Qwen3-TTS-family fine-tunes; all should stay external until a same-text listening test proves they are better for the target PDF than SILMA/Habibi or Emirati VITS Male. Keep NAMAA-Saudi-TTS-V2 out of the default/free production lane because it is non-commercial, reference-audio based, and explicitly Najdi/Saudi rather than MSA book narration.
|
| 316 |
+
|
| 317 |
+
Use NAMAA-Egyptian-TTS only for Egyptian/dialectal experiments. It is MIT and Chatterbox-based with local and hosted examples, but its own model card targets conversational Egyptian Arabic rather than Modern Standard Arabic books.
|
| 318 |
+
|
| 319 |
+
Keep Arabic-F5-TTS-v2 out of the default public app. It may be useful for personal MSA experiments, but it is non-commercial and requires fully diacritized Arabic input.
|
| 320 |
+
|
| 321 |
+
Use VoxCPM2 only as a future strong-worker benchmark. It is permissively licensed and supports Arabic, but its 2B size and GPU-oriented requirements make it a poor fit for the first free CPU family deployment.
|
| 322 |
+
|
| 323 |
+
Use Voxtral TTS only for personal/non-commercial strong-worker benchmarks. It supports Arabic on the model card, but its license and GPU guidance make it the wrong default for a public family website.
|
| 324 |
+
|
| 325 |
+
Use eSpeak NG when speed and reliability matter more than naturalness, or when the neural voices are not installed on the worker.
|
| 326 |
+
|
| 327 |
+
The voice benchmark now reports both:
|
| 328 |
+
|
| 329 |
+
```text
|
| 330 |
+
recommended -> quality-first starting voice, usually silma-local
|
| 331 |
+
fastest -> fastest successful voice in the current run
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
The final voice choice still requires listening to the generated files, because Arabic pacing and comfort over long religious/commentary texts cannot be judged by runtime alone.
|
| 335 |
+
|
| 336 |
+
## Optional Arabic-Trained VLM OCR Worker Paths
|
| 337 |
+
|
| 338 |
+
The repo includes optional KATIB, Arabic-Qwen, and QARI-OCR wiring for hard Arabic scans. For maximum quality on a short sample or strong worker, use:
|
| 339 |
+
|
| 340 |
+
```text
|
| 341 |
+
OCR_ENGINE=arabic-max
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
`arabic-max` tries QARI-OCR, KATIB, Arabic-Qwen, Baseer, PaddleOCR-VL, PaddleOCR Arabic, EasyOCR Arabic, Surya, and Tesseract Arabic where installed, then keeps the cleanest text by the same speech-readiness score used by the app.
|
| 345 |
+
|
| 346 |
+
Try KATIB first when you want a smaller Arabic-trained model:
|
| 347 |
+
|
| 348 |
+
```text
|
| 349 |
+
OCR_ENGINE=katib-ocr
|
| 350 |
+
KATIB_OCR_RENDER_ZOOM=1.5
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
Install it locally on Windows with:
|
| 354 |
+
|
| 355 |
+
```powershell
|
| 356 |
+
.\scripts\setup_katib_ocr.ps1
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
KATIB 0.8B is Apache-2.0 and fine-tuned specifically for Arabic OCR, including printed and handwritten text. It is still a VLM path, so benchmark it on a small sample before using it for a full book.
|
| 360 |
+
|
| 361 |
+
Try Arabic-Qwen3.5-OCR-v4 as a second smaller Arabic-trained model:
|
| 362 |
+
|
| 363 |
+
```text
|
| 364 |
+
OCR_ENGINE=arabic-qwen-ocr
|
| 365 |
+
ARABIC_QWEN_OCR_RENDER_ZOOM=1.5
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
Install it locally on Windows with:
|
| 369 |
+
|
| 370 |
+
```powershell
|
| 371 |
+
.\scripts\setup_arabic_qwen_ocr.ps1
|
| 372 |
+
```
|
| 373 |
+
|
| 374 |
+
Arabic-Qwen3.5-OCR-v4 is Apache-2.0 and smaller than QARI. It is still a VLM path, so use it on short samples or a worker and promote it only if it beats the existing wired OCR stack on the same pages.
|
| 375 |
+
|
| 376 |
+
QARI-OCR remains the stronger/heavier Arabic-book path:
|
| 377 |
+
|
| 378 |
+
```text
|
| 379 |
+
OCR_ENGINE=qari-ocr
|
| 380 |
+
QARI_OCR_RENDER_ZOOM=1.5
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
Install it locally on Windows with:
|
| 384 |
+
|
| 385 |
+
```powershell
|
| 386 |
+
.\scripts\setup_qari_ocr.ps1
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
The QARI sidecar installs `transformers`, `qwen-vl-utils`, `accelerate`, `peft`, PyTorch, torchvision, and Pillow, then checks that the image-to-text model classes import successfully. The first real run downloads the model weights, so expect the first QARI test to take longer than later tests.
|
| 390 |
+
|
| 391 |
+
Then benchmark it against the lighter engines:
|
| 392 |
+
|
| 393 |
+
```powershell
|
| 394 |
+
python scripts\benchmark_ocr.py C:\path\to\arabic-book.pdf --page-limit 1 --engines arabic baseer-ocr arabic-qwen-ocr katib-ocr qari-ocr paddleocr tesseract
|
| 395 |
+
```
|
| 396 |
+
|
| 397 |
+
`OCR_ENGINE=katib-ocr` tries KATIB first, then falls back to PaddleOCR, EasyOCR, and Tesseract. `OCR_ENGINE=arabic-qwen-ocr` tries Arabic-Qwen first, then falls back to PaddleOCR, EasyOCR, and Tesseract. `OCR_ENGINE=baseer-ocr` tries Baseer first, then falls back to PaddleOCR, EasyOCR, and Tesseract. `OCR_ENGINE=qari-ocr` tries QARI first, then falls back to PaddleOCR, EasyOCR, and Tesseract. `OCR_ENGINE=arabic` includes installed KATIB, Arabic-Qwen, Baseer, and QARI automatically unless `ARABIC_INCLUDE_KATIB_OCR=0`, `ARABIC_INCLUDE_ARABIC_QWEN_OCR=0`, `ARABIC_INCLUDE_BASEER_OCR=0`, or `ARABIC_INCLUDE_QARI_OCR=0` is set. `OCR_ENGINE=arabic-max` always opts into the heavy Arabic OCR comparison path where those sidecars are installed. `OCR_ENGINE=best` does not include those VLM engines unless `BEST_INCLUDE_KATIB_OCR=1`, `BEST_INCLUDE_ARABIC_QWEN_OCR=1`, `BEST_INCLUDE_BASEER_OCR=1`, or `BEST_INCLUDE_QARI_OCR=1` is set, because they can be much slower and heavier than the default OCR path.
|
| 398 |
+
|
| 399 |
+
## Optional Surya Worker Path
|
| 400 |
+
|
| 401 |
+
The repo includes optional Surya wiring for hard scanned books. This remains useful when PaddleOCR-VL is unavailable or when you want to compare a Datalab document OCR path:
|
| 402 |
+
|
| 403 |
+
```text
|
| 404 |
+
OCR_ENGINE=surya
|
| 405 |
+
SURYA_RENDER_ZOOM=1.5
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
`OCR_ENGINE=surya` tries Surya first, then falls back to PaddleOCR, EasyOCR, and Tesseract. `OCR_ENGINE=best` does not include Surya unless `BEST_INCLUDE_SURYA=1` is set, because Surya can be much slower and heavier than the default OCR path.
|
| 409 |
+
|
| 410 |
+
Install it only on a worker or powerful local machine:
|
| 411 |
+
|
| 412 |
+
```powershell
|
| 413 |
+
.\scripts\setup_surya.ps1
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
+
Surya also needs `llama.cpp` / `llama-server` for CPU or Apple Silicon, or `vLLM` for NVIDIA GPU. The setup script installs the Python sidecar but does not install those heavier backend runtimes.
|
| 417 |
+
|
| 418 |
+
## Latest High-End OCR Watchlist
|
| 419 |
+
|
| 420 |
+
The current implementation intentionally avoids making a heavy VLM the default, but these are the free options to revisit when the worker has enough RAM/GPU:
|
| 421 |
+
|
| 422 |
+
1. QARI-OCR 0.4: strongest Arabic-specific upgrade candidate because it is directly trained for Arabic OCR on Islamic books and Arabic manuscripts.
|
| 423 |
+
2. KATIB 0.8B: smaller Arabic-trained OCR VLM to try when QARI is too heavy.
|
| 424 |
+
3. PaddleOCR-VL-1.6: best general document-parser candidate because it is in the official PaddleOCR ecosystem and supports 109 languages.
|
| 425 |
+
4. Ketaba-OCR LoRA: Arabic manuscript LoRA candidate to benchmark externally when the wired Arabic OCR stack fails difficult scans.
|
| 426 |
+
5. Qari-OCR-LoRA: secondary QARI-family manuscript LoRA benchmark after Ketaba and QARI 0.4.
|
| 427 |
+
6. Tawkeed OCR: wired Apache-2.0 Arabic-first QARI v0.3 fork to benchmark when QARI 0.4 is too heavy or edge-style Arabic OCR matters.
|
| 428 |
+
7. Falcon-OCR: compact Apache-2.0 300M document OCR VLM worth benchmarking when Arabic VLM OCR is too heavy or when layout/table handling matters.
|
| 429 |
+
8. Baseer OCR V1.0: Arabic-specialized and Apache-2.0, worth tracking for difficult Arabic legal or complex-layout pages.
|
| 430 |
+
9. Arabic-GLM-OCR-v2: newer Arabic OCR VLM optional sidecar that needs short-sample scoring before full-book use.
|
| 431 |
+
10. Arabic-Qwen3.5-OCR-v4: 0.9B Apache-2.0 Arabic OCR VLM wired as an optional sidecar; benchmark it beside KATIB/QARI on the target pages.
|
| 432 |
+
11. DeepSeek-OCR-2: official Apache-2.0 3B general document OCR VLM to benchmark externally on strong-worker hardware.
|
| 433 |
+
12. DeepSeek Arabic OCR v6: newer Arabic-labeled DeepSeek-OCR fine-tune to benchmark externally before considering any sidecar work.
|
| 434 |
+
13. Arabic-English handwritten OCR Qwen3-VL: 4B handwritten Arabic/English OCR watchlist model to benchmark externally for handwriting-heavy pages.
|
| 435 |
+
14. Arabic-English handwritten OCR v3: 3B-class handwritten Arabic/English OCR watchlist model to benchmark externally when manuscript/handwriting pages are the main failure.
|
| 436 |
+
15. Arabic handwritten OCR 4-bit Qwen2.5-VL: smaller Apache-2.0 handwriting/manuscript OCR checkpoint to benchmark when memory is tight.
|
| 437 |
+
16. NAKBA Arabic manuscript line OCR baseline: 2026 manuscript-line benchmark to test only after pages are cropped into lines.
|
| 438 |
+
17. HAFITH: Apache-2.0 historical Arabic manuscript line-OCR benchmark to test only after page images are segmented into text lines.
|
| 439 |
+
18. Glimpse RTL OCR: Apache-2.0 Arabic/Persian line-OCR benchmark to test only after page images are segmented into text lines.
|
| 440 |
+
19. olmOCR Arabic LoRA v2: full-page Arabic manuscript LoRA benchmark for large workers.
|
| 441 |
+
20. Arabic Large Nougat: GPL-3.0 Arabic book-page OCR-to-Markdown benchmark to test externally for printed books.
|
| 442 |
+
21. Arabic OCR Qwen2.5-VL GGUF: QariOCR-trained Arabic/English GGUF/Unsloth benchmark for scanned books, religious text, handwriting, and mixed-language documents.
|
| 443 |
+
22. Qwen3-VL Persian/Arabic line OCR: Apache-2.0 line-level OCR model to benchmark only when pages can be cropped into text lines.
|
| 444 |
+
23. DIMI Arabic OCR v2: high-capacity Qwen2.5-VL-7B LoRA to benchmark externally for printed Arabic and diacritics-heavy pages.
|
| 445 |
+
24. Loay Arabic-OCR-Qwen2.5-VL-7B: high-capacity Arabic OCR VLM to benchmark externally when smaller Arabic OCR models are not enough.
|
| 446 |
+
25. AtlasOCR: Darija/Moroccan Arabic OCR model to test only when the source PDFs need that dialect coverage.
|
| 447 |
+
26. Arabic Legal Documents OCR 1.0: Gemma-licensed legal/structured-document OCR benchmark for legal PDFs only; not a default audiobook OCR engine.
|
| 448 |
+
27. Kraken/eScriptorium Arabic script: open-source historical/non-Latin OCR workflow to test when pages look like archival Arabic print or manuscripts; model choice and line segmentation make it external-only for this app.
|
| 449 |
+
28. Kairawan/Qalamus manuscript OCR: free web-service evidence for Arabic manuscript transcription, but service-only and not a deployable free-software worker component yet.
|
| 450 |
+
29. Surya / Chandra: useful document parsing alternatives for layout-heavy scans, but less clean as a default because model-weight licensing and runtime needs are more complicated.
|
| 451 |
+
|
| 452 |
+
## Latest High-End TTS Watchlist
|
| 453 |
+
|
| 454 |
+
The current implementation keeps SILMA as the default local neural voice because it is Arabic-focused, permissively licensed, and practical to install as a sidecar. The strongest free/open voice candidates to revisit on a bigger worker are:
|
| 455 |
+
|
| 456 |
+
1. Supertonic 3: OpenRAIL, Arabic among 31 supported languages, 99M parameters, ONNX CPU inference, and now wired as optional `supertonic-ar`. Benchmark it as the fast local CPU challenger.
|
| 457 |
+
2. 3arab-TTS 500M: Apache-2.0, Arabic-only, about 500M parameters, and a VoiceDesign variant. Benchmark it on the same cleaned text before any app wiring because it is very new.
|
| 458 |
+
3. KaniTTS Arabic: Arabic-only, about 400M parameters, and speed-focused, but license metadata needs confirmation. Benchmark it on the same cleaned text before any app wiring.
|
| 459 |
+
4. Emirati VITS Male: Apache-2.0 bilingual Emirati Arabic/English. Benchmark only for Gulf dialect fit.
|
| 460 |
+
5. MOSS-TTS-Nano: Apache-2.0, Arabic among 20 supported languages, 0.1B parameters, packaged CLI, and ONNX CPU path. Benchmark it with an Arabic reference voice as another CPU-friendly candidate.
|
| 461 |
+
6. OmniVoice plus Arabic LoRA: Apache-2.0, Arabic included in 646-language coverage, and a separate Arabic LoRA adapter exists. Benchmark it externally as the newest broad multilingual candidate.
|
| 462 |
+
7. Lahgtna OmniVoice v2: Arabic dialect OmniVoice fine-tune; benchmark externally for dialectal content and confirm licensing before any production wiring.
|
| 463 |
+
8. Lahgtna Chatterbox: MIT Arabic dialect TTS; benchmark externally for dialectal content and watch for repetition on long text.
|
| 464 |
+
9. Egyptian Arabic Chatterbox: Apache-2.0 Egyptian dialect Chatterbox fine-tune; benchmark only for Egyptian Arabic content.
|
| 465 |
+
10. NileTTS-XTTS: Apache-2.0 Egyptian Arabic XTTS fine-tune with 2026 paper results; benchmark only for Egyptian/dialectal content.
|
| 466 |
+
11. Arabic XTTS-v2 Egyptian fine-tune: promising Egyptian speaker quality work, but CPML/base-license review is required before any personal/public use decision.
|
| 467 |
+
12. Chatterbox-Multilingual: MIT, Arabic listed among 23 supported languages, and simple local examples. Benchmark it with the same Arabic reference voice and listen for long-form pacing.
|
| 468 |
+
13. Chatterbox-Multilingual ONNX: MIT ONNX packaging with Arabic support. Benchmark it for free CPU/ONNX worker practicality before wiring.
|
| 469 |
+
13. tts-arabic-onnx: compact Arabic-only ONNX FastPitch/MixerTTS package. Benchmark CPU speed, speaker IDs, pace, and vowelizers, but confirm license fit before production.
|
| 470 |
+
14. Mishkala, Tashkeel-350M, and Mushkil: Apache-2.0 pronunciation-preprocessor benchmarks. Test plain, lightweight tashkeel, larger-model tashkeel, and AraT5V2 tashkeel on the same voice before changing production settings.
|
| 471 |
+
15. Thaka KSAA-2026 speech diacritization: research-only signal for speech-plus-text Arabic diacritization; wait for permissive code/weights before any app work.
|
| 472 |
+
15. Spark-TTS Arabic: Apache-2.0 Classical/MSA candidate, but requires diacritized input and the Spark-TTS repo.
|
| 473 |
+
16. Sofelia-TTS: Apache-2.0 Palestinian Arabic voice benchmark for dialectal content.
|
| 474 |
+
17. VoxCPM2: Apache-2.0, Arabic among 30 supported languages, 48 kHz output, and voice design/cloning. Benchmark it only on GPU/large-worker hardware.
|
| 475 |
+
18. Voxtral TTS: Arabic listed and high-quality open weights, but CC-BY-NC-4.0 and 4B GPU-oriented deployment. Benchmark only for personal/non-commercial strong-worker comparisons.
|
| 476 |
+
19. Habibi-TTS MSA: Arabic-specific and already wired as an optional voice; continue comparing it against SILMA for MSA passages.
|
| 477 |
+
20. Egyptian Arabic Qwen3-TTS: Apache-2.0 Qwen3-TTS dialect fine-tune to benchmark only for Egyptian Arabic; not the MSA audiobook default.
|
| 478 |
+
21. Saudi Arabic Qwen3-TTS: Apache-2.0 Qwen3-TTS dialect fine-tune to benchmark only for Saudi/Gulf Arabic.
|
| 479 |
+
22. Emirati Qwen3.5-TTS: Apache-2.0 Qwen3-TTS-family dialect fine-tune to benchmark beside Emirati VITS Male for Emirati/Gulf content.
|
| 480 |
+
23. Arabic-F5-TTS-v2: personal/non-commercial MSA candidate that requires tashkeel; do not use as the default public free website voice.
|
| 481 |
+
24. Qwen3-TTS: Apache-2.0 and strong generally, but official released model cards list Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, and Italian. Do not promote the base model until Arabic support and a practical Arabic inference path are verified from primary sources.
|
| 482 |
+
|
| 483 |
+
For the current father-friendly website, the right process is still: run a 5-page sample, benchmark `arabic-max` and the lighter Arabic OCR modes, export the same cleaned TTS sample, listen to SILMA/Habibi/Supertonic/eSpeak samples, optionally test Mishkala/Tashkeel-350M/Mushkil preprocessing, 3arab-TTS 500M, KaniTTS Arabic, Emirati VITS Male, MOSS-TTS-Nano, OmniVoice/Arabic LoRA, Lahgtna OmniVoice v2, Lahgtna Chatterbox, NAMAA-Saudi-TTS, Saudi Chatterbox fine-tune, Saudi TTS, Egyptian Arabic Chatterbox, NileTTS-XTTS, Arabic XTTS-v2 Egyptian fine-tune, Chatterbox-Multilingual, Chatterbox-Multilingual ONNX, tts-arabic-onnx, Spark-TTS Arabic, Sofelia-TTS, Egyptian Arabic Qwen3-TTS, Saudi Arabic Qwen3-TTS, Emirati Qwen3.5-TTS, or personal/non-commercial Voxtral TTS externally with that exact text, then process the full book only after text quality is good.
|
| 484 |
+
|
| 485 |
+
Do not confuse "free/open model" with "available as a hosted inference endpoint." Current Hugging Face model cards show QARI-OCR 0.4 and SILMA TTS as free/open candidates, but not served as a simple hosted Hugging Face inference route and not deployed by a hosted inference provider. That is why the recommended public website remains Vercel for the UI plus a Docker worker for OCR/TTS, not Vercel-only cloud TTS.
|
| 486 |
+
|
| 487 |
+
Keep audio storage deliberately simple for the free hosted path. The Docker worker stores finished MP3/WAV files locally under `outputs/` and the app exposes download URLs until retention cleanup removes old files. That fits the father-friendly goal because the user downloads the audio when it is ready. Permanent hosted audio links should be a later opt-in using Vercel Blob or another object store, because long audiobook files can consume free storage, transfer, operation, and edge-request quotas quickly.
|
| 488 |
+
|
| 489 |
+
If the wired OCR engines are not clean enough, export fixed page images before trying external OCR models:
|
| 490 |
+
|
| 491 |
+
```powershell
|
| 492 |
+
python scripts\export_ocr_sample_images.py C:\path\to\arabic-book.pdf --out-dir outputs\external-ocr-sample
|
| 493 |
+
```
|
| 494 |
+
|
| 495 |
+
Use the generated `external-ocr-sample.md` handoff and compare external QARI-OCR 0.4 GGUF, Loay Arabic-OCR-DeepSeek-OCR-2, DeepSeek-OCR-2, DeepSeek Arabic OCR v6, aNS Qwen3-VL Arabic OCR v3, Waraqon v3 Arabic OCR HTML Qari, Ketaba-OCR, Qari-OCR-LoRA, oi-OCR, NuExtract3, Qianfan-OCR, Chandra OCR 2, dots.ocr, Arabic Large Nougat, Kraken/eScriptorium Arabic script, Kairawan/Qalamus manuscript OCR, HAFITH, Glimpse RTL OCR, olmOCR Arabic LoRA v2, Falcon-OCR, DIMI Arabic OCR v2, Loay Arabic-OCR-Qwen2.5-VL-7B, Arabic Legal Documents OCR 1.0, Arabic-English handwritten OCR Qwen3-VL, Arabic-English handwritten OCR v3, Arabic handwritten OCR 4-bit Qwen2.5-VL, NAKBA Arabic manuscript line OCR baseline, Arabic OCR Qwen2.5-VL GGUF, Qwen3-VL Persian/Arabic line OCR, AtlasOCR, or similar OCR outputs against the wired Arabic stack on those exact same page images. Tawkeed, Arabic-GLM-OCR-v2, Arabic-Qwen3.5-OCR-v4, and Baseer are already wired as optional sidecars, but the same sample images are still useful if you want to score them externally before installing the sidecar.
|
| 496 |
+
|
| 497 |
+
After the external OCR model writes text, score its output and save JSON for the promotion gate:
|
| 498 |
+
|
| 499 |
+
```powershell
|
| 500 |
+
python scripts\benchmark_ocr.py test_pdfs\book-best-5-pages.pdf --page-limit 5 --engines arabic-max arabic tawkeed-ocr baseer-ocr arabic-qwen-ocr katib-ocr qari-ocr paddleocr tesseract --json > outputs\external-ocr-sample\wired-ocr-baseline.json
|
| 501 |
+
python scripts\score_external_ocr.py --candidate qari-gguf=outputs\external-ocr-sample\qari-gguf.txt --candidate loay-deepseek-ocr-2=outputs\external-ocr-sample\loay-deepseek-ocr-2.txt --candidate ketaba=outputs\external-ocr-sample\ketaba.txt --candidate loay-qwen25=outputs\external-ocr-sample\loay-qwen25.txt --candidate legal-docs-ocr=outputs\external-ocr-sample\legal-docs-ocr.txt --candidate nuextract3=outputs\external-ocr-sample\nuextract3.txt --candidate chandra=outputs\external-ocr-sample\chandra.txt --candidate dots-ocr=outputs\external-ocr-sample\dots-ocr.txt --candidate arabic-large-nougat=outputs\external-ocr-sample\arabic-large-nougat.txt --candidate kraken=outputs\external-ocr-sample\kraken.txt --candidate handwritten-4bit=outputs\external-ocr-sample\handwritten-4bit.txt --candidate olmocr-arabic-lora=outputs\external-ocr-sample\olmocr-arabic-lora.txt --candidate qwen25-gguf=outputs\external-ocr-sample\qwen25-gguf.txt --candidate falcon=outputs\external-ocr-sample\falcon.txt --candidate arabic-glm=outputs\external-ocr-sample\arabic-glm.txt --baseline-json outputs\external-ocr-sample\wired-ocr-baseline.json --write-report outputs\external-ocr-sample\external-ocr-score.md --write-json outputs\external-ocr-sample\external-ocr-score.json
|
| 502 |
+
python scripts\model_promotion_gate.py --candidate-name "External OCR winner" --kind ocr --license Apache-2.0 --score-json outputs\external-ocr-sample\external-ocr-score.json --same-sample --runtime-ok --privacy-ok --human-reviewed --write-report outputs\model-promotion-gate.md
|
| 503 |
+
```
|
| 504 |
+
|
| 505 |
+
The repo now includes a deliberately optional PaddleOCR-VL sidecar:
|
| 506 |
+
|
| 507 |
+
```powershell
|
| 508 |
+
.\scripts\setup_paddleocr_vl.ps1
|
| 509 |
+
python scripts\benchmark_ocr.py C:\path\to\arabic-book.pdf --page-limit 1 --engines arabic arabic-qwen-ocr katib-ocr qari-ocr paddleocr paddleocr-vl tesseract
|
| 510 |
+
```
|
| 511 |
+
|
| 512 |
+
Use `OCR_ENGINE=paddleocr-vl` only for short samples or a strong worker. It falls back to regular PaddleOCR, EasyOCR, and Tesseract when the sidecar is unavailable or fails. `OCR_ENGINE=best` does not include PaddleOCR-VL unless `BEST_INCLUDE_PADDLEOCR_VL=1` is set.
|
| 513 |
+
|
| 514 |
+
## Source Links
|
| 515 |
+
|
| 516 |
+
- EasyOCR: https://github.com/JaidedAI/EasyOCR
|
| 517 |
+
- PaddleOCR PP-OCRv5 multilingual recognition: https://github.com/PaddlePaddle/PaddleOCR/blob/main/docs/version3.x/algorithm/PP-OCRv5/PP-OCRv5_multi_languages.en.md
|
| 518 |
+
- PaddleOCR OCR pipeline: https://www.paddleocr.ai/main/en/version3.x/pipeline_usage/OCR.html
|
| 519 |
+
- PaddleOCR latest docs: https://www.paddleocr.ai/latest/en/index.html
|
| 520 |
+
- PP-OCRv5 paper: https://arxiv.org/abs/2603.24373
|
| 521 |
+
- QARI-OCR 0.4 model: https://huggingface.co/NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct
|
| 522 |
+
- QARI-OCR 0.4 GGUF: https://huggingface.co/marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF
|
| 523 |
+
- QARI-OCR v0.3 lighter model: https://huggingface.co/NAMAA-Space/Qari-OCR-v0.3-VL-2B-Instruct
|
| 524 |
+
- QARI-OCR paper: https://arxiv.org/abs/2506.02295
|
| 525 |
+
- KATIB 0.8B Arabic OCR model: https://huggingface.co/oddadmix/Katib-Qwen3.5-0.8B-0.1
|
| 526 |
+
- Ketaba-OCR LoRA: https://huggingface.co/HassanB4/Ketaba-OCR-LoRA
|
| 527 |
+
- Qari-OCR-LoRA: https://huggingface.co/HassanB4/Qari-OCR-LoRA
|
| 528 |
+
- Tawkeed OCR: https://huggingface.co/tawkeed-sa/tawkeed-ocr
|
| 529 |
+
- PaddleOCR-VL-1.6 model: https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6
|
| 530 |
+
- PaddleOCR-VL-1.6 paper: https://arxiv.org/abs/2606.03264
|
| 531 |
+
- oi-OCR: https://huggingface.co/oi-uae/oi-OCR
|
| 532 |
+
- NuExtract3: https://huggingface.co/numind/NuExtract3
|
| 533 |
+
- Qianfan-OCR: https://huggingface.co/baidu/Qianfan-OCR
|
| 534 |
+
- Chandra OCR 2: https://github.com/datalab-to/chandra
|
| 535 |
+
- dots.ocr: https://huggingface.co/rednote-hilab/dots.ocr
|
| 536 |
+
- Arabic Large Nougat: https://huggingface.co/MohamedRashad/arabic-large-nougat
|
| 537 |
+
- DocTR Arabic FAST detector: https://huggingface.co/madskills/doctr-fast_base-arabic
|
| 538 |
+
- DocTR Arabic PARSEQ recognizer: https://huggingface.co/madskills/doctr-parseq-arabic
|
| 539 |
+
- Kraken OCR documentation: https://kraken.re/main/index.html
|
| 540 |
+
- eScriptorium overview: https://escriptorium.eu/about
|
| 541 |
+
- HAFITH: https://huggingface.co/mdnaseif/hafith
|
| 542 |
+
- Glimpse RTL OCR: https://huggingface.co/surfiniaburger/unsloth_finetune_ocr_arabic
|
| 543 |
+
- olmOCR Arabic LoRA v2: https://huggingface.co/hastyle/olmOCR-arabic-lora-v2
|
| 544 |
+
- GLM-OCR Arabic/French documents: https://huggingface.co/maloukafer/GLM-OCR-finetuned-documents
|
| 545 |
+
- mimoha Arabic OCR: https://huggingface.co/mimoha/ocr
|
| 546 |
+
- Falcon-OCR: https://huggingface.co/tiiuae/Falcon-OCR
|
| 547 |
+
- Falcon Perception paper: https://arxiv.org/abs/2603.27365
|
| 548 |
+
- Baseer OCR model: https://huggingface.co/AbdoTarek/Baseer-OCR-V1.0
|
| 549 |
+
- Baseer OCR paper: https://arxiv.org/abs/2509.18174
|
| 550 |
+
- Arabic-GLM-OCR-v2: https://huggingface.co/sherif1313/Arabic-GLM-OCR-v2
|
| 551 |
+
- Arabic-Qwen3.5-OCR-v4: https://huggingface.co/sherif1313/Arabic-Qwen3.5-OCR-v4
|
| 552 |
+
- DeepSeek-OCR-2: https://huggingface.co/deepseek-ai/DeepSeek-OCR-2
|
| 553 |
+
- Loay Arabic-OCR-DeepSeek-OCR-2: https://huggingface.co/loay/Arabic-OCR-DeepSeek-OCR-2
|
| 554 |
+
- DeepSeek Arabic OCR v6: https://huggingface.co/melsiddieg/deepseek_ocr_arabic_v6
|
| 555 |
+
- Arabic-English handwritten OCR Qwen3-VL: https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B
|
| 556 |
+
- Arabic-English handwritten OCR v3: https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-v3
|
| 557 |
+
- Waraqon v3 Arabic OCR HTML Qari: https://huggingface.co/FatimahEmadEldin/Waraqon-v3-Arabic-OCR-HTML-Qari
|
| 558 |
+
- Arabic handwritten OCR 4-bit Qwen2.5-VL: https://huggingface.co/sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3
|
| 559 |
+
- NAKBA Arabic manuscript line OCR baseline: https://huggingface.co/U4RASD/ar-ms-baseline
|
| 560 |
+
- Arabic OCR Qwen2.5-VL GGUF: https://huggingface.co/mo1998/arabic-ocr-qwen2.5-vl
|
| 561 |
+
- Qwen3-VL Persian/Arabic line OCR: https://huggingface.co/mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0
|
| 562 |
+
- Kairawan/Qalamus manuscript OCR: https://kairawan.org/
|
| 563 |
+
- DIMI Arabic OCR v2: https://huggingface.co/AhmedZaky1/DIMI-Arabic-OCR-V2
|
| 564 |
+
- Raqim post-OCR correction: https://www.sciencedirect.com/science/article/pii/S187705092600058X
|
| 565 |
+
- Loay Arabic-OCR-Qwen2.5-VL-7B: https://huggingface.co/loay/Arabic-OCR-Qwen2.5-VL-7B-Vision
|
| 566 |
+
- AtlasOCR: https://huggingface.co/atlasia/AtlasOCR
|
| 567 |
+
- Arabic Legal Documents OCR 1.0: https://huggingface.co/bakrianoo/arabic-legal-documents-ocr-1.0
|
| 568 |
+
- Surya: https://github.com/datalab-to/surya
|
| 569 |
+
- SILMA TTS: https://huggingface.co/silma-ai/silma-tts
|
| 570 |
+
- SILMA open source Arabic TTS models: https://silma.ai/open-source-arabic-tts-models
|
| 571 |
+
- SILMA Hugging Face launch article: https://huggingface.co/blog/silma-ai/opensource-arabic-english-text-to-speech-model
|
| 572 |
+
- SILMA Arabic TTS benchmark: https://silma.ai/arabic-tts-benchmark
|
| 573 |
+
- Habibi-TTS: https://github.com/SWivid/Habibi-TTS
|
| 574 |
+
- Habibi-TTS paper: https://arxiv.org/abs/2601.13802
|
| 575 |
+
- Mishkala Tashkeel: https://huggingface.co/flokymind/mishkala
|
| 576 |
+
- Tashkeel-350M: https://huggingface.co/Etherll/Tashkeel-350M
|
| 577 |
+
- Mushkil: https://huggingface.co/riotu-lab/mushkil
|
| 578 |
+
- Thaka KSAA-2026 speech diacritization: https://arxiv.org/abs/2605.25928
|
| 579 |
+
- KSAA-2026 shared task: https://www.codabench.org/competitions/11859/
|
| 580 |
+
- Supertonic 3: https://huggingface.co/Supertone/supertonic-3
|
| 581 |
+
- MOSS-TTS-Nano: https://github.com/OpenMOSS/MOSS-TTS-Nano
|
| 582 |
+
- 3arab-TTS 500M: https://huggingface.co/sherif1313/3arab-TTS-500M-v1
|
| 583 |
+
- 3arab-TTS 500M VoiceDesign: https://huggingface.co/sherif1313/3arab-TTS-500M-v1-VoiceDesign
|
| 584 |
+
- KaniTTS Arabic: https://huggingface.co/nineninesix/kani-tts-400m-ar
|
| 585 |
+
- Emirati VITS Male: https://huggingface.co/vadimbelsky/emirati-vits-male-1.0
|
| 586 |
+
- OmniVoice: https://huggingface.co/k2-fsa/OmniVoice
|
| 587 |
+
- OmniVoice Arabic LoRA: https://huggingface.co/vivooglobal/omnivoice-lora-ar
|
| 588 |
+
- Lahgtna OmniVoice v2: https://huggingface.co/oddadmix/lahgtna-omnivoice-v2
|
| 589 |
+
- Lahgtna Chatterbox: https://huggingface.co/oddadmix/lahgtna-chatterbox-v1
|
| 590 |
+
- NAMAA-Saudi-TTS: https://huggingface.co/NAMAA-Space/NAMAA-Saudi-TTS
|
| 591 |
+
- NAMAA-Saudi-TTS-V2: https://huggingface.co/NAMAA-Space/NAMAA-Saudi-TTS-V2
|
| 592 |
+
- NAMAA-Egyptian-TTS: https://huggingface.co/NAMAA-Space/NAMAA-Egyptian-TTS
|
| 593 |
+
- Saudi Chatterbox fine-tune: https://huggingface.co/FatimahEmadEldin/saudi-tts-chatterbox-finetuned
|
| 594 |
+
- Saudi TTS: https://huggingface.co/AhmedEladl/saudi-tts
|
| 595 |
+
- Egyptian Arabic Chatterbox: https://huggingface.co/AliAbdallah/egyptian-arabic-tts-chatterbox
|
| 596 |
+
- NileTTS-XTTS: https://huggingface.co/KickItLikeShika/NileTTS-XTTS
|
| 597 |
+
- Arabic XTTS-v2 Egyptian fine-tune: https://huggingface.co/Moeeldouma/arabic-tts-xtts-v2
|
| 598 |
+
- NileTTS paper: https://arxiv.org/abs/2602.15675
|
| 599 |
+
- Chatterbox-Multilingual: https://github.com/resemble-ai/chatterbox
|
| 600 |
+
- Chatterbox Arabic fine-tune: https://huggingface.co/juliardi/chatterbox-multilingual-finetuned-arabic
|
| 601 |
+
- Chatterbox-Multilingual ONNX: https://huggingface.co/onnx-community/chatterbox-multilingual-ONNX
|
| 602 |
+
- tts-arabic-onnx: https://huggingface.co/nipponjo/tts-arabic-onnx
|
| 603 |
+
- tts_arabic repository: https://github.com/nipponjo/tts_arabic
|
| 604 |
+
- Spark-TTS Arabic: https://huggingface.co/azeddinShr/Spark-TTS-Arabic-Complete
|
| 605 |
+
- Sofelia-TTS: https://huggingface.co/hamdallah/Sofelia-TTS
|
| 606 |
+
- Arabic-F5-TTS-v2: https://huggingface.co/IbrahimSalah/Arabic-F5-TTS-v2
|
| 607 |
+
- Egyptian Arabic Qwen3-TTS: https://huggingface.co/itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base
|
| 608 |
+
- Saudi Arabic Qwen3-TTS: https://huggingface.co/vadimbelsky/qwen3-TTS-KSA
|
| 609 |
+
- Emirati Qwen3.5-TTS: https://huggingface.co/vadimbelsky/qwen3.5-TTS-Emirati
|
| 610 |
+
- VoxCPM2: https://huggingface.co/openbmb/VoxCPM2
|
| 611 |
+
- VoxCPM paper: https://arxiv.org/abs/2509.24650
|
| 612 |
+
- Voxtral TTS: https://huggingface.co/mistralai/Voxtral-4B-TTS-2603
|
| 613 |
+
- Voxtral TTS paper: https://arxiv.org/abs/2603.25551
|
| 614 |
+
- Qwen3-TTS 0.6B Base: https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base
|
| 615 |
+
- Qwen3-TTS 1.7B Base: https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
| 616 |
+
- Qwen3-TTS technical report: https://arxiv.org/abs/2601.15621
|
docs/father-user-guide.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Arabic PDF Reader User Guide
|
| 2 |
+
|
| 3 |
+
This guide is for the person using the website, not for setup.
|
| 4 |
+
|
| 5 |
+
## Normal Use
|
| 6 |
+
|
| 7 |
+
1. Open the website.
|
| 8 |
+
2. Enter the access code.
|
| 9 |
+
3. Choose the Arabic PDF.
|
| 10 |
+
4. Leave **Voice** on the best Arabic voice unless someone tells you to change it.
|
| 11 |
+
5. Leave **Text quality** on **Maximum Arabic-trained OCR** for a new scanned book.
|
| 12 |
+
6. Leave **Pages** on **Quick test** first.
|
| 13 |
+
7. Press **Create Audio**.
|
| 14 |
+
8. Wait until the status says the audio is ready.
|
| 15 |
+
9. Listen to the short test.
|
| 16 |
+
10. If it sounds good, change **Pages** to **Full book** and press **Create Audio** again.
|
| 17 |
+
11. Press **Download Audio** when the full audio is ready.
|
| 18 |
+
|
| 19 |
+
## Which Text Quality To Choose
|
| 20 |
+
|
| 21 |
+
Use **Maximum Arabic-trained OCR** first. It is the safest choice for scanned Arabic books because it compares Arabic-trained OCR results and keeps the cleanest text.
|
| 22 |
+
|
| 23 |
+
Use **QARI Arabic books** for a difficult scanned book when the normal option reads badly and the worker is strong enough. It is trained for Arabic books and manuscripts, but it can be much slower.
|
| 24 |
+
|
| 25 |
+
Use **KATIB Arabic OCR** when QARI is too slow or too heavy. It is also trained for Arabic text and is smaller.
|
| 26 |
+
|
| 27 |
+
Use **Best scan test** only on a short sample. It is useful for deciding which OCR engine works best for one book, but it is too slow for most full books.
|
| 28 |
+
|
| 29 |
+
Use **PaddleOCR Arabic** when the test sounds good and you want the full book to run faster.
|
| 30 |
+
|
| 31 |
+
Use **Tesseract Arabic fallback** when the other options are broken or when a benchmark says Tesseract worked best for that book.
|
| 32 |
+
|
| 33 |
+
## If Something Fails
|
| 34 |
+
|
| 35 |
+
If the app says the text quality is poor, do not make full-book audio yet. Try **Maximum Arabic-trained OCR**, then **Best scan test**, then **QARI Arabic books** or **KATIB Arabic OCR** if either is available.
|
| 36 |
+
|
| 37 |
+
If the first pages are title pages or blank pages, make a 5-page test PDF from better pages and test that before the full book.
|
| 38 |
+
|
| 39 |
+
If the website is running through a hosted worker, the job can keep running while the browser is open and checking progress. If the worker restarts, the recent audio list will show the job as failed instead of leaving it stuck.
|
| 40 |
+
|
| 41 |
+
If the hosted website says downloadable cloud audio needs the worker, use **Read aloud** only as a temporary listening option. That browser voice can help you hear the text, but the normal **Download Audio** button comes back only after the worker is connected.
|
| 42 |
+
|
| 43 |
+
If the audio sounds robotic but the text is correct, try another voice. **SILMA Arabic Best** is the preferred free local voice when it is available.
|
| 44 |
+
|
| 45 |
+
## Best Routine For A New Book
|
| 46 |
+
|
| 47 |
+
1. Run a quick test.
|
| 48 |
+
2. Listen.
|
| 49 |
+
3. If the Arabic text sounds wrong, change OCR before changing the voice.
|
| 50 |
+
4. If the Arabic text sounds right but the voice is not comfortable, change the voice.
|
| 51 |
+
5. Only run the full book after the short test sounds good.
|
docs/huggingface-model-metadata.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Model Metadata
|
| 2 |
+
|
| 3 |
+
Last refreshed: June 6, 2026.
|
| 4 |
+
|
| 5 |
+
Generated by `scripts/check_research_sources.py --check-hf-metadata`.
|
| 6 |
+
|
| 7 |
+
Rows marked `page-only` use verified public model-page evidence when live Hugging Face API/socket metadata is unavailable in the local environment.
|
| 8 |
+
|
| 9 |
+
| Model | Status | Reported ID | License | License Check | Last Modified |
|
| 10 |
+
| --- | --- | --- | --- | --- | --- |
|
| 11 |
+
| AbdoTarek/Baseer-OCR-V1.0 | FAIL | - | - | - | - |
|
| 12 |
+
| AhmedEladl/saudi-tts | PASS | AhmedEladl/saudi-tts | apache-2.0 | PASS | page-only |
|
| 13 |
+
| AhmedZaky1/DIMI-Arabic-OCR-V2 | FAIL | - | - | - | - |
|
| 14 |
+
| AliAbdallah/egyptian-arabic-tts-chatterbox | FAIL | - | - | - | - |
|
| 15 |
+
| atlasia/AtlasOCR | FAIL | - | - | - | - |
|
| 16 |
+
| azeddinShr/Spark-TTS-Arabic-Complete | FAIL | - | - | - | - |
|
| 17 |
+
| baidu/Qianfan-OCR | PASS | baidu/Qianfan-OCR | apache-2.0 | PASS | page-only |
|
| 18 |
+
| bakrianoo/arabic-legal-documents-ocr-1.0 | FAIL | - | - | - | - |
|
| 19 |
+
| bilalRHCH/Arabic-text-to-speech | PASS | bilalRHCH/Arabic-text-to-speech | apache-2.0 | PASS | page-only |
|
| 20 |
+
| datalab-to/chandra | PASS | datalab-to/chandra | openrail | - | page-only |
|
| 21 |
+
| deepseek-ai/DeepSeek-OCR-2 | FAIL | - | - | - | - |
|
| 22 |
+
| Etherll/Tashkeel-350M | FAIL | - | - | - | - |
|
| 23 |
+
| FatimahEmadEldin/saudi-tts-chatterbox-finetuned | FAIL | - | - | - | - |
|
| 24 |
+
| flokymind/mishkala | FAIL | - | - | - | - |
|
| 25 |
+
| hamdallah/Sofelia-TTS | FAIL | - | - | - | - |
|
| 26 |
+
| HassanB4/Ketaba-OCR-LoRA | PASS | HassanB4/Ketaba-OCR-LoRA | apache-2.0 | PASS | page-only |
|
| 27 |
+
| HassanB4/Qari-OCR-LoRA | PASS | HassanB4/Qari-OCR-LoRA | apache-2.0 | PASS | page-only |
|
| 28 |
+
| hastyle/olmOCR-arabic-lora-v2 | FAIL | - | - | - | - |
|
| 29 |
+
| HumeAI/tada-3b-ml | FAIL | - | - | - | - |
|
| 30 |
+
| IbrahimSalah/Arabic-F5-TTS-v2 | PASS | IbrahimSalah/Arabic-F5-TTS-v2 | fair-noncommercial-research-license | - | page-only |
|
| 31 |
+
| itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base | FAIL | - | - | - | - |
|
| 32 |
+
| juliardi/chatterbox-multilingual-finetuned-arabic | FAIL | - | - | - | - |
|
| 33 |
+
| k2-fsa/OmniVoice | FAIL | - | - | - | - |
|
| 34 |
+
| KickItLikeShika/NileTTS-XTTS | FAIL | - | - | - | - |
|
| 35 |
+
| loay/Arabic-OCR-DeepSeek-OCR-2 | FAIL | - | - | - | - |
|
| 36 |
+
| loay/Arabic-OCR-Qwen2.5-VL-7B-Vision | FAIL | - | - | - | - |
|
| 37 |
+
| madskills/doctr-fast_base-arabic | FAIL | - | - | - | - |
|
| 38 |
+
| madskills/doctr-parseq-arabic | FAIL | - | - | - | - |
|
| 39 |
+
| maloukafer/GLM-OCR-finetuned-documents | FAIL | - | - | - | - |
|
| 40 |
+
| marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF | FAIL | - | - | - | - |
|
| 41 |
+
| mdnaseif/hafith | FAIL | - | - | - | - |
|
| 42 |
+
| melsiddieg/deepseek_ocr_arabic_v6 | FAIL | - | - | - | - |
|
| 43 |
+
| mimoha/ocr | FAIL | - | - | - | - |
|
| 44 |
+
| mistralai/Voxtral-4B-TTS-2603 | PASS | mistralai/Voxtral-4B-TTS-2603 | cc-by-nc-4.0 | PASS | page-only |
|
| 45 |
+
| mo1998/arabic-ocr-qwen2.5-vl | FAIL | - | - | - | - |
|
| 46 |
+
| Moeeldouma/arabic-tts-xtts-v2 | PASS | Moeeldouma/arabic-tts-xtts-v2 | coqui-public-model-license | PASS | page-only |
|
| 47 |
+
| mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0 | FAIL | - | - | - | - |
|
| 48 |
+
| MohamedRashad/arabic-large-nougat | PASS | MohamedRashad/arabic-large-nougat | gpl-3.0 | PASS | page-only |
|
| 49 |
+
| NAMAA-Space/NAMAA-Egyptian-TTS | FAIL | - | - | - | - |
|
| 50 |
+
| NAMAA-Space/NAMAA-Saudi-TTS | PASS | NAMAA-Space/NAMAA-Saudi-TTS | mit | PASS | page-only |
|
| 51 |
+
| NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct | PASS | NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct | apache-2.0 | PASS | page-only |
|
| 52 |
+
| nineninesix/kani-tts-400m-ar | FAIL | - | - | - | - |
|
| 53 |
+
| nipponjo/tts-arabic-onnx | FAIL | - | - | - | - |
|
| 54 |
+
| numind/NuExtract3 | FAIL | - | - | - | - |
|
| 55 |
+
| oddadmix/Katib-Qwen3.5-0.8B-0.1 | PASS | oddadmix/Katib-Qwen3.5-0.8B-0.1 | apache-2.0 | PASS | page-only |
|
| 56 |
+
| oddadmix/lahgtna-chatterbox-v1 | FAIL | - | - | - | - |
|
| 57 |
+
| oddadmix/lahgtna-omnivoice-v2 | FAIL | - | - | - | - |
|
| 58 |
+
| oi-uae/oi-OCR | FAIL | - | - | - | - |
|
| 59 |
+
| onnx-community/chatterbox-multilingual-ONNX | FAIL | - | - | - | - |
|
| 60 |
+
| openbmb/VoxCPM2 | FAIL | - | - | - | - |
|
| 61 |
+
| PaddlePaddle/PaddleOCR-VL-1.6 | FAIL | - | - | - | - |
|
| 62 |
+
| Qwen/Qwen3-TTS-12Hz-0.6B-Base | FAIL | - | - | - | - |
|
| 63 |
+
| Qwen/Qwen3-TTS-12Hz-1.7B-Base | FAIL | - | - | - | - |
|
| 64 |
+
| rednote-hilab/dots.ocr | FAIL | - | - | - | - |
|
| 65 |
+
| sherif1313/3arab-TTS-500M-v1 | FAIL | - | - | - | - |
|
| 66 |
+
| sherif1313/3arab-TTS-500M-v1-VoiceDesign | FAIL | - | - | - | - |
|
| 67 |
+
| sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B | FAIL | - | - | - | - |
|
| 68 |
+
| sherif1313/Arabic-English-handwritten-OCR-v3 | FAIL | - | - | - | - |
|
| 69 |
+
| sherif1313/Arabic-GLM-OCR-v2 | FAIL | - | - | - | - |
|
| 70 |
+
| sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3 | FAIL | - | - | - | - |
|
| 71 |
+
| sherif1313/Arabic-Qwen3.5-OCR-v4 | FAIL | - | - | - | - |
|
| 72 |
+
| silma-ai/silma-tts | PASS | silma-ai/silma-tts | apache-2.0 | PASS | page-only |
|
| 73 |
+
| Supertone/supertonic-3 | PASS | Supertone/supertonic-3 | openrail | PASS | page-only |
|
| 74 |
+
| surfiniaburger/unsloth_finetune_ocr_arabic | FAIL | - | - | - | - |
|
| 75 |
+
| tawkeed-sa/tawkeed-ocr | PASS | tawkeed-sa/tawkeed-ocr | apache-2.0 | PASS | page-only |
|
| 76 |
+
| tiiuae/Falcon-OCR | FAIL | - | - | - | - |
|
| 77 |
+
| U4RASD/ar-ms-baseline | FAIL | - | - | - | - |
|
| 78 |
+
| vadimbelsky/emirati-vits-male-1.0 | FAIL | - | - | - | - |
|
| 79 |
+
| vadimbelsky/qwen3-TTS-KSA | FAIL | - | - | - | - |
|
| 80 |
+
| vadimbelsky/qwen3.5-TTS-Emirati | FAIL | - | - | - | - |
|
| 81 |
+
| vivooglobal/omnivoice-lora-ar | FAIL | - | - | - | - |
|
docs/live-deployment-checklist.md
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Live Deployment Checklist
|
| 2 |
+
|
| 3 |
+
Last checked: June 5, 2026.
|
| 4 |
+
|
| 5 |
+
Use this checklist when you are ready to make the Arabic PDF Reader live for a family user. The goal is to keep the website simple while proving that the deployed worker can handle scanned Arabic OCR, create audio, and serve downloads.
|
| 6 |
+
|
| 7 |
+
After deployment, give the non-technical user `docs/father-user-guide.md`.
|
| 8 |
+
|
| 9 |
+
Official references:
|
| 10 |
+
|
| 11 |
+
- Vercel FastAPI deployment: https://vercel.com/docs/frameworks/backend/fastapi
|
| 12 |
+
- Vercel environment variables: https://vercel.com/docs/environment-variables
|
| 13 |
+
- Hugging Face Docker Spaces: https://huggingface.co/docs/hub/main/en/spaces-sdks-docker
|
| 14 |
+
- Hugging Face Spaces overview: https://huggingface.co/docs/hub/main/spaces-overview
|
| 15 |
+
|
| 16 |
+
## 1. Local Package Check
|
| 17 |
+
|
| 18 |
+
Run this before touching hosting:
|
| 19 |
+
|
| 20 |
+
```powershell
|
| 21 |
+
python scripts\check_deployment_readiness.py
|
| 22 |
+
python scripts\audit_goal_readiness.py
|
| 23 |
+
python scripts\prove_local_readiness.py --refresh-research --check-hf-metadata --hf-metadata-report outputs\hf-model-metadata.md
|
| 24 |
+
python scripts\check_research_sources.py
|
| 25 |
+
python scripts\check_research_sources.py --check-key-links
|
| 26 |
+
python scripts\check_research_sources.py --check-representative-links
|
| 27 |
+
python scripts\check_research_sources.py --check-hf-metadata --write-hf-metadata-report outputs\hf-model-metadata.md
|
| 28 |
+
python scripts\export_hf_space.py --force
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Expected result:
|
| 32 |
+
|
| 33 |
+
```text
|
| 34 |
+
Deployment readiness: no FAIL
|
| 35 |
+
Goal audit: no FAIL; live-site and live-worker WARN entries until both reports exist
|
| 36 |
+
Local readiness: ready, report written to outputs\local-readiness.json; metadata report written to outputs\hf-model-metadata.md
|
| 37 |
+
Research sources: no FAIL; key and representative source links return HTTP 2xx/3xx; Hugging Face metadata reports expected model IDs and known licenses; `outputs\hf-model-metadata.md` records model freshness/license metadata
|
| 38 |
+
outputs\huggingface-space exists
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
After you know the Hugging Face Space URL and Vercel production URL, generate a handoff sheet:
|
| 42 |
+
|
| 43 |
+
```powershell
|
| 44 |
+
python scripts\deployment_handoff.py https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
This writes `outputs\deployment-handoff.md` with the exact worker secrets, Vercel environment variables, Hugging Face/Vercel CLI deploy commands, and final proof commands.
|
| 48 |
+
It also generates a real `SECRET_KEY` for the worker and Vercel site and marks whether the URLs look real. Keep `outputs\deployment-handoff.md` private because it contains that deployment secret. If the handoff says `URLs look real: no`, replace placeholder, local, test, or reserved `example.*` URLs before running the live proof; the final audit will reject placeholder-looking evidence.
|
| 49 |
+
|
| 50 |
+
To do the bundle export and handoff in one step, run:
|
| 51 |
+
|
| 52 |
+
```powershell
|
| 53 |
+
python scripts\prepare_live_deployment.py --worker-url https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
This refreshes `outputs\huggingface-space`, writes `outputs\deployment-quickstart.md`, writes `outputs\deployment-handoff.md`, writes `outputs\vercel-production.env`, `outputs\worker-secrets.env`, `outputs\worker-space-settings.md`, and `outputs\apply-vercel-env.ps1`, and saves `outputs\deployment-prep.json` with the next command to run.
|
| 57 |
+
Add `--secret-key <existing-secret>` only if you intentionally need to reuse a previous cookie-signing secret.
|
| 58 |
+
|
| 59 |
+
If you do not know the live URLs yet, run:
|
| 60 |
+
|
| 61 |
+
```powershell
|
| 62 |
+
python scripts\prepare_live_deployment.py
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
This still refreshes the Hugging Face Space bundle and writes `outputs\deployment-quickstart.md`, which explains what to create on Hugging Face and Vercel before the final handoff can be generated.
|
| 66 |
+
|
| 67 |
+
At any point, this command tells you the next proof step:
|
| 68 |
+
|
| 69 |
+
```powershell
|
| 70 |
+
python scripts\next_deployment_step.py --worker-url https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Use this fuller status command when you want to see whether saved reports are real live evidence or stale/test artifacts:
|
| 74 |
+
|
| 75 |
+
```powershell
|
| 76 |
+
python scripts\deployment_status.py --worker-url https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
After `prepare_live_deployment.py` generates the Vercel and worker `.env` files, validate them first:
|
| 80 |
+
|
| 81 |
+
```powershell
|
| 82 |
+
python scripts\validate_deployment_env.py --vercel-env outputs\vercel-production.env --worker-env outputs\worker-secrets.env --worker-url https://your-space.hf.space --origin https://your-vercel-app.vercel.app
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
This catches the most common production mistake: leaving direct Hugging Face cloud TTS enabled on Vercel instead of routing downloadable audio through `WORKER_BASE_URL`.
|
| 86 |
+
|
| 87 |
+
After the Hugging Face Space and Vercel production site are both deployed, run the quick Vercel worker diagnostic before uploading a large PDF:
|
| 88 |
+
|
| 89 |
+
```powershell
|
| 90 |
+
python scripts\hosted_preflight.py https://your-vercel-app.vercel.app --code 1234 --worker-url https://your-space.hf.space
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
The preflight writes `outputs\hosted-preflight.json` and must pass both `site worker reachable from vercel` and `site worker CORS ready`. If it says `cors-blocked`, set Hugging Face `CORS_ORIGINS` to the exact Vercel production URL, keep `COOKIE_SAMESITE=none` and `COOKIE_SECURE=1`, restart the Space, redeploy Vercel, and run the preflight again.
|
| 94 |
+
|
| 95 |
+
## 2. Hugging Face Worker
|
| 96 |
+
|
| 97 |
+
Create a Hugging Face Space with SDK set to **Docker**. Upload or push the generated folder:
|
| 98 |
+
|
| 99 |
+
```text
|
| 100 |
+
outputs\huggingface-space
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
The default Docker build installs the practical free CPU stack: SILMA, PaddleOCR Arabic, Tesseract Arabic, and eSpeak NG. For the first stronger worker, start with the smaller Arabic-trained OCR sidecars before the very heavy QARI path:
|
| 104 |
+
|
| 105 |
+
```text
|
| 106 |
+
INSTALL_TAWKEED_OCR=1
|
| 107 |
+
INSTALL_KATIB_OCR=1
|
| 108 |
+
INSTALL_ARABIC_QWEN_OCR=1
|
| 109 |
+
INSTALL_ARABIC_GLM_OCR=1
|
| 110 |
+
INSTALL_BASEER_OCR=1
|
| 111 |
+
INSTALL_SUPERTONIC=1
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
If you only want to enable one smaller Arabic-trained OCR VLM first, use KATIB:
|
| 115 |
+
|
| 116 |
+
```text
|
| 117 |
+
INSTALL_KATIB_OCR=1
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
For a stronger worker that can handle the heavier Arabic-book VLM OCR, set this too:
|
| 121 |
+
|
| 122 |
+
```text
|
| 123 |
+
INSTALL_QARI_OCR=1
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
For a second smaller Arabic-trained OCR VLM, set this too:
|
| 127 |
+
|
| 128 |
+
```text
|
| 129 |
+
INSTALL_ARABIC_QWEN_OCR=1
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
For the Arabic-GLM OCR sidecar, set this too:
|
| 133 |
+
|
| 134 |
+
```text
|
| 135 |
+
INSTALL_ARABIC_GLM_OCR=1
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
For the Arabic-first Tawkeed/QARI v0.3 fork, set this too:
|
| 139 |
+
|
| 140 |
+
```text
|
| 141 |
+
INSTALL_TAWKEED_OCR=1
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
For the optional fast CPU comparison voice, set:
|
| 145 |
+
|
| 146 |
+
```text
|
| 147 |
+
INSTALL_SUPERTONIC=1
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Only add heavy OCR build args on hardware with enough RAM/GPU. Leave them off for the first free CPU deployment, then enable Tawkeed, KATIB, Arabic-Qwen, Baseer, or QARI after the 5-page benchmark shows the model improves the target book. Supertonic is lighter than the OCR VLMs, but still optional because SILMA remains the Arabic-focused default.
|
| 151 |
+
|
| 152 |
+
Set these Space secrets:
|
| 153 |
+
|
| 154 |
+
```text
|
| 155 |
+
ACCESS_CODE=1234
|
| 156 |
+
SECRET_KEY=<generated by outputs\deployment-handoff.md>
|
| 157 |
+
CORS_ORIGINS=https://your-vercel-app.vercel.app
|
| 158 |
+
COOKIE_SAMESITE=none
|
| 159 |
+
COOKIE_SECURE=1
|
| 160 |
+
OCR_ENGINE=arabic-max
|
| 161 |
+
DEFAULT_VOICE_ID=silma-local
|
| 162 |
+
OUTPUT_RETENTION_DAYS=7
|
| 163 |
+
OUTPUT_MAX_FILES=25
|
| 164 |
+
AUDIO_FORMAT=mp3
|
| 165 |
+
MP3_BITRATE=96k
|
| 166 |
+
SILMA_ENABLE_NORMALIZER=0
|
| 167 |
+
SILMA_FORCE_TASHKEEL=0
|
| 168 |
+
SILMA_NORMALIZE_NUMBERS=0
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
Keep `OCR_ENGINE=arabic-max` for the first real deployment. It compares the strongest installed Arabic OCR outputs instead of trusting one engine.
|
| 172 |
+
|
| 173 |
+
## 3. Vercel Website
|
| 174 |
+
|
| 175 |
+
Deploy the repo on Vercel as a Python/FastAPI project. Set Vercel environment variables:
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
ACCESS_CODE=1234
|
| 179 |
+
SECRET_KEY=<generated by outputs\deployment-handoff.md>
|
| 180 |
+
WORKER_BASE_URL=https://your-space.hf.space
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
Remove any temporary direct-cloud TTS variables you added during testing:
|
| 184 |
+
|
| 185 |
+
```powershell
|
| 186 |
+
vercel env rm ENABLE_DIRECT_CLOUD_TTS production --yes
|
| 187 |
+
vercel env rm HF_API_TOKEN production --yes
|
| 188 |
+
vercel env rm HF_TTS_MODEL production --yes
|
| 189 |
+
vercel env rm DEFAULT_VOICE_ID production --yes
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
It is fine if Vercel says one of those variables does not exist. In production, downloadable audio should come from `WORKER_BASE_URL`, not the direct Hugging Face fallback.
|
| 193 |
+
|
| 194 |
+
Temporary cloud fallback for short tests only:
|
| 195 |
+
|
| 196 |
+
```text
|
| 197 |
+
HF_API_TOKEN=<hugging-face-token>
|
| 198 |
+
HF_TTS_MODEL=facebook/mms-tts-ara
|
| 199 |
+
DEFAULT_VOICE_ID=mms-ara
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
Use Vercel's Production environment for the final public URL. Vercel must point at the worker with `WORKER_BASE_URL`; otherwise large scanned books fall back to browser OCR/cloud TTS and will be much slower.
|
| 203 |
+
|
| 204 |
+
### Optional CLI Worker Upload
|
| 205 |
+
|
| 206 |
+
If you have a Hugging Face write token, the repo can upload the prepared Docker Space bundle for you:
|
| 207 |
+
|
| 208 |
+
```powershell
|
| 209 |
+
.\.venv\Scripts\python.exe -m pip install huggingface_hub
|
| 210 |
+
$env:HF_TOKEN="<your-hugging-face-write-token>"
|
| 211 |
+
.\.venv\Scripts\python.exe scripts\export_hf_space.py --force
|
| 212 |
+
.\.venv\Scripts\python.exe scripts\deploy_hf_space.py your-username/arabic-audio-reader-worker --json
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
The script creates or updates a Docker Space and prints the worker URL, normally `https://your-username-arabic-audio-reader-worker.hf.space`. Use that exact URL as Vercel `WORKER_BASE_URL`.
|
| 216 |
+
|
| 217 |
+
After the worker URL is live, wire it into Vercel and verify the hosted shell in one step:
|
| 218 |
+
|
| 219 |
+
```powershell
|
| 220 |
+
.\.venv\Scripts\python.exe scripts\configure_vercel_worker.py https://your-username-arabic-audio-reader-worker.hf.space --site-url https://arabic-translator-mu.vercel.app --code 1234 --verify
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
Or run the whole remaining deployment flow after setting `HF_TOKEN`:
|
| 224 |
+
|
| 225 |
+
```powershell
|
| 226 |
+
.\.venv\Scripts\python.exe scripts\finish_live_deployment.py your-username/arabic-audio-reader-worker --site-url https://arabic-translator-mu.vercel.app --code 1234 --dry-run
|
| 227 |
+
.\.venv\Scripts\python.exe scripts\finish_live_deployment.py your-username/arabic-audio-reader-worker --site-url https://arabic-translator-mu.vercel.app --code 1234
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
## 4. Live Worker Evidence
|
| 231 |
+
|
| 232 |
+
After the worker URL is live and Vercel has a production URL, run:
|
| 233 |
+
|
| 234 |
+
```powershell
|
| 235 |
+
python scripts\prove_live_deployment.py https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234 --smoke-ocr-engine arabic-max --check-hf-metadata --hf-metadata-report outputs\hf-model-metadata.md --proof-out outputs\live-deployment-proof.json
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
Replace both placeholder URLs first. The live proof command intentionally refuses placeholder, local, non-HTTPS, swapped Hugging Face/Vercel, or same-service URLs before it makes any network calls.
|
| 239 |
+
|
| 240 |
+
That command verifies the Vercel shell and worker, writes `outputs\site-verification.json`, `outputs\worker-verification.json`, and `outputs\live-deployment-proof.json`, and runs the final goal audit against the saved evidence. `outputs\live-deployment-proof.json` should contain `"complete": true` when the deployed system is proven. The manual equivalent is:
|
| 241 |
+
|
| 242 |
+
```powershell
|
| 243 |
+
python scripts\verify_site.py https://your-vercel-app.vercel.app --code 1234 --worker-url https://your-space.hf.space --json > outputs\site-verification.json
|
| 244 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors
|
| 245 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max --json > outputs\worker-verification.json
|
| 246 |
+
python scripts\audit_goal_readiness.py --worker-report outputs\worker-verification.json --site-report outputs\site-verification.json
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
The final audit is complete only when the saved Vercel site report proves login, Vercel platform mode, configured `WORKER_BASE_URL`, large-PDF readiness, disabled direct cloud fallback, and that Vercel can reach the worker diagnostics endpoint, and the saved worker report proves both embedded-text and scanned-OCR smoke checks:
|
| 250 |
+
|
| 251 |
+
```text
|
| 252 |
+
site login
|
| 253 |
+
site platform vercel
|
| 254 |
+
site worker configured
|
| 255 |
+
site large PDF ready
|
| 256 |
+
site production worker ready
|
| 257 |
+
site hosted limits documented
|
| 258 |
+
site recommended stack documented
|
| 259 |
+
site direct cloud fallback disabled
|
| 260 |
+
site worker diagnostics endpoint
|
| 261 |
+
site worker reachable from vercel
|
| 262 |
+
site worker CORS ready
|
| 263 |
+
recommended stack documented
|
| 264 |
+
smoke upload accepted
|
| 265 |
+
smoke job complete
|
| 266 |
+
smoke usable text
|
| 267 |
+
smoke audio url
|
| 268 |
+
smoke download url
|
| 269 |
+
smoke audio bytes
|
| 270 |
+
smoke audio file signature
|
| 271 |
+
smoke download bytes
|
| 272 |
+
smoke download file signature
|
| 273 |
+
scanned smoke upload accepted
|
| 274 |
+
scanned smoke job complete
|
| 275 |
+
scanned smoke usable text
|
| 276 |
+
scanned smoke OCR extraction
|
| 277 |
+
scanned smoke audio url
|
| 278 |
+
scanned smoke download url
|
| 279 |
+
scanned smoke audio bytes
|
| 280 |
+
scanned smoke audio file signature
|
| 281 |
+
scanned smoke download bytes
|
| 282 |
+
scanned smoke download file signature
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
## 5. First Real Arabic Book
|
| 286 |
+
|
| 287 |
+
Before processing a 100 MB+ book, create a small representative sample:
|
| 288 |
+
|
| 289 |
+
```powershell
|
| 290 |
+
python scripts\select_test_pages.py C:\path\to\arabic-book.pdf --count 5 --out test_pdfs\book-best-5-pages.pdf
|
| 291 |
+
python scripts\prepare_book_workflow.py test_pdfs\book-best-5-pages.pdf --sample-pages 1 --engine-preset balanced --benchmark-voices --write-report outputs\book-prep-report.md --write-env outputs\recommended-ocr.env
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
Use the report to choose the cleanest OCR and most comfortable voice. Only run the full book after:
|
| 295 |
+
|
| 296 |
+
```text
|
| 297 |
+
quality=good
|
| 298 |
+
readyForTts=true
|
| 299 |
+
the short audio sample sounds acceptable
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
If the lighter engines read badly and the worker is strong enough, add Tawkeed, Arabic-Qwen, KATIB, Baseer, and QARI-OCR to the benchmark:
|
| 303 |
+
|
| 304 |
+
```powershell
|
| 305 |
+
python scripts\prepare_book_workflow.py test_pdfs\book-best-5-pages.pdf --sample-pages 1 --engine-preset maximum --benchmark-voices --write-report outputs\book-prep-report.md --write-env outputs\recommended-ocr.env
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
For scanned Arabic books, QARI is the best Arabic-book OCR to try on a strong worker. Tawkeed, KATIB, Arabic-Qwen, and Baseer are better first upgrades when the worker is smaller. Keep `OCR_ENGINE=arabic-max` after installing any of them so the worker can compare Arabic-trained OCR against the practical fallbacks on the same pages.
|
| 309 |
+
|
| 310 |
+
## 6. If The Free Worker Is Too Slow
|
| 311 |
+
|
| 312 |
+
Free CPU workers can sleep and cold-start. Hugging Face's current free CPU Basic Space default is 2 vCPU, 16 GB RAM, and 50 GB non-persistent disk. That is acceptable for a personal demo, but long scanned books may take hours and generated audio should be treated as short-lived unless you add persistent/object storage. If the first real book is too slow:
|
| 313 |
+
|
| 314 |
+
1. Keep Vercel as the website.
|
| 315 |
+
2. Move only the worker to a stronger CPU/GPU host or a family computer.
|
| 316 |
+
3. Keep the same `WORKER_BASE_URL` pattern.
|
| 317 |
+
4. Re-run `verify_worker.py --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max --json` and `audit_goal_readiness.py --worker-report`.
|
docs/production-worker-architecture.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production Worker Architecture
|
| 2 |
+
|
| 3 |
+
Last checked: June 2026.
|
| 4 |
+
|
| 5 |
+
## Short Answer
|
| 6 |
+
|
| 7 |
+
Use Vercel for the website shell and a separate worker for the heavy Arabic PDF-to-audio job.
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
Vercel website
|
| 11 |
+
-> upload PDF or send job to worker
|
| 12 |
+
-> worker runs Arabic specialist OCR + SILMA
|
| 13 |
+
-> worker stores MP3/WAV
|
| 14 |
+
-> website shows progress and download link
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
The current local FastAPI app already behaves like that worker. `Dockerfile.worker` packages that worker for Docker hosts such as Hugging Face Spaces Docker, Render, or a small VPS.
|
| 18 |
+
|
| 19 |
+
## Why Not Vercel Alone
|
| 20 |
+
|
| 21 |
+
Vercel is excellent for the front door, but large Arabic scanned PDFs and neural TTS are long-running jobs. Vercel Hobby functions have limited duration, memory, and bundle size. Even when Fluid Compute is enabled, the free Hobby maximum is measured in minutes, while a 100 MB scanned book can take much longer.
|
| 22 |
+
|
| 23 |
+
## Free/Low-Cost Host Reality
|
| 24 |
+
|
| 25 |
+
| Host | Fit | Caveat |
|
| 26 |
+
| --- | --- | --- |
|
| 27 |
+
| Hugging Face Spaces CPU | Best free demo worker candidate | Free CPU Spaces sleep when unused and may cold-start slowly. |
|
| 28 |
+
| Render free web service | Possible test worker | Free instances are for previews/hobby use and can suspend. |
|
| 29 |
+
| Vercel Hobby | Good website host | Not a good place for EasyOCR/PaddleOCR/SILMA on large scanned PDFs. |
|
| 30 |
+
| Fly.io | Good low-cost app host | New users no longer get a real always-free tier. |
|
| 31 |
+
| Home PC or family mini PC | Best free always-on worker if available | Needs the computer online during jobs. |
|
| 32 |
+
|
| 33 |
+
## Worker Dockerfile
|
| 34 |
+
|
| 35 |
+
Build locally:
|
| 36 |
+
|
| 37 |
+
```powershell
|
| 38 |
+
python scripts\check_deployment_readiness.py
|
| 39 |
+
docker build -f Dockerfile.worker -t arabic-audio-worker .
|
| 40 |
+
docker run --rm -p 7860:7860 --env ACCESS_CODE=1234 --env SECRET_KEY=change-me arabic-audio-worker
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
For a stronger worker that actually installs the heavy Arabic-book OCR sidecar during the Docker build:
|
| 44 |
+
|
| 45 |
+
```powershell
|
| 46 |
+
docker build -f Dockerfile.worker --build-arg INSTALL_QARI_OCR=1 -t arabic-audio-worker:qari .
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
For a smaller Arabic-trained OCR worker, try KATIB first:
|
| 50 |
+
|
| 51 |
+
```powershell
|
| 52 |
+
docker build -f Dockerfile.worker --build-arg INSTALL_KATIB_OCR=1 -t arabic-audio-worker:katib .
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
For Arabic-Qwen3.5-OCR-v4:
|
| 56 |
+
|
| 57 |
+
```powershell
|
| 58 |
+
docker build -f Dockerfile.worker --build-arg INSTALL_ARABIC_QWEN_OCR=1 -t arabic-audio-worker:arabic-qwen .
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
For an experimental document-parser worker, add PaddleOCR-VL too:
|
| 62 |
+
|
| 63 |
+
```powershell
|
| 64 |
+
docker build -f Dockerfile.worker --build-arg INSTALL_QARI_OCR=1 --build-arg INSTALL_KATIB_OCR=1 --build-arg INSTALL_ARABIC_QWEN_OCR=1 --build-arg INSTALL_BASEER_OCR=1 --build-arg INSTALL_PADDLEOCR_VL=1 --build-arg INSTALL_SUPERTONIC=1 -t arabic-audio-worker:vl .
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
Do not use those build args on a small free CPU worker unless you are comfortable with much slower builds, larger images, cold starts, and possible memory failures.
|
| 68 |
+
|
| 69 |
+
Then open:
|
| 70 |
+
|
| 71 |
+
```text
|
| 72 |
+
http://127.0.0.1:7860
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
The default image installs:
|
| 76 |
+
|
| 77 |
+
- FastAPI app runtime.
|
| 78 |
+
- Tesseract with Arabic language data.
|
| 79 |
+
- eSpeak NG fallback voice.
|
| 80 |
+
- SILMA TTS sidecar.
|
| 81 |
+
- EasyOCR sidecar.
|
| 82 |
+
- PaddleOCR sidecar.
|
| 83 |
+
|
| 84 |
+
The worker bundle also includes setup scripts for optional heavy paths, but they are not installed by default in the free CPU image:
|
| 85 |
+
|
| 86 |
+
- QARI-OCR sidecar for Arabic-native OCR on strong workers.
|
| 87 |
+
- KATIB sidecar for smaller Arabic-trained OCR on workers.
|
| 88 |
+
- Arabic-Qwen sidecar for 0.9B Arabic-trained OCR on workers.
|
| 89 |
+
- PaddleOCR-VL sidecar for general VLM document parsing.
|
| 90 |
+
- Habibi-TTS MSA sidecar for voice comparison.
|
| 91 |
+
- Optional Supertonic 3 sidecar for fast CPU Arabic-capable voice comparison.
|
| 92 |
+
- Surya OCR sidecar for hard scans on larger workers.
|
| 93 |
+
|
| 94 |
+
`Dockerfile.worker` exposes `INSTALL_QARI_OCR=1`, `INSTALL_KATIB_OCR=1`, `INSTALL_ARABIC_QWEN_OCR=1`, `INSTALL_BASEER_OCR=1`, `INSTALL_PADDLEOCR_VL=1`, and `INSTALL_SUPERTONIC=1` build args so stronger workers can install QARI-OCR, KATIB, Arabic-Qwen, Baseer, PaddleOCR-VL, and the optional Supertonic CPU voice without editing the Dockerfile. This keeps the free CPU image practical while making the higher-quality free OCR paths and fast voice comparison path deployable.
|
| 95 |
+
|
| 96 |
+
QARI-OCR 0.4 is the strongest Arabic-native OCR upgrade to test for a stronger worker. It is a 4B VLM fine-tuned for Islamic books and Arabic manuscripts, so keep it out of the default free CPU family-site worker unless a short Arabic-book benchmark proves it improves the actual pages and the worker has enough RAM/GPU. KATIB 0.8B and Arabic-Qwen3.5-OCR-v4 are the smaller Arabic-trained OCR upgrades to try when QARI is too heavy. If the worker is too small for QARI, set `QARI_OCR_MODEL=NAMAA-Space/Qari-OCR-v0.3-VL-2B-Instruct` to test the lighter older QARI path. PaddleOCR-VL-1.6 remains the main general document-parser upgrade to watch. In the website, leave the dropdown on `Maximum Arabic-trained OCR`; the worker will use whichever Arabic-trained sidecars are installed and fall back only when they are unavailable or lose the benchmark.
|
| 97 |
+
|
| 98 |
+
The repo includes optional KATIB, Arabic-Qwen, QARI-OCR, and PaddleOCR-VL sidecar scripts for this evaluation path:
|
| 99 |
+
|
| 100 |
+
```powershell
|
| 101 |
+
.\scripts\setup_qari_ocr.ps1
|
| 102 |
+
.\scripts\setup_katib_ocr.ps1
|
| 103 |
+
.\scripts\setup_arabic_qwen_ocr.ps1
|
| 104 |
+
.\scripts\setup_paddleocr_vl.ps1
|
| 105 |
+
python scripts\benchmark_ocr.py C:\path\to\arabic-book.pdf --page-limit 1 --engines arabic arabic-qwen-ocr katib-ocr qari-ocr paddleocr paddleocr-vl tesseract
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Do not add KATIB, Arabic-Qwen, QARI-OCR, or PaddleOCR-VL to the default free CPU Docker worker unless a real benchmark shows it improves the target book and the worker can handle the memory/runtime. For a stronger worker, set `OCR_ENGINE=arabic-max` for maximum quality comparison, or set `OCR_ENGINE=katib-ocr`, `OCR_ENGINE=arabic-qwen-ocr`, `OCR_ENGINE=qari-ocr`, or `OCR_ENGINE=paddleocr-vl` only after the short-sample benchmark wins.
|
| 109 |
+
|
| 110 |
+
## Hugging Face Spaces Docker Path
|
| 111 |
+
|
| 112 |
+
For the click-by-click live setup and final evidence commands, use:
|
| 113 |
+
|
| 114 |
+
```text
|
| 115 |
+
docs/live-deployment-checklist.md
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Create a new Space:
|
| 119 |
+
|
| 120 |
+
1. Choose **Docker** as the Space SDK.
|
| 121 |
+
2. Export a clean Space bundle:
|
| 122 |
+
|
| 123 |
+
```powershell
|
| 124 |
+
python scripts\export_hf_space.py --force
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
3. Upload or push the generated `outputs\huggingface-space` folder to the Space repo. It contains `Dockerfile` copied from `Dockerfile.worker`, only the needed app files, and no local `.env`, uploads, outputs, data, tests, or virtualenvs.
|
| 128 |
+
4. Set secrets:
|
| 129 |
+
- `ACCESS_CODE`
|
| 130 |
+
- `SECRET_KEY`
|
| 131 |
+
- `CORS_ORIGINS=https://your-vercel-app.vercel.app`
|
| 132 |
+
- `COOKIE_SAMESITE=none`
|
| 133 |
+
- `COOKIE_SECURE=1`
|
| 134 |
+
5. Keep `OCR_ENGINE=arabic-max` for quality, or switch to `OCR_ENGINE=paddleocr` only when you need a faster single-engine run.
|
| 135 |
+
|
| 136 |
+
Then set this on Vercel:
|
| 137 |
+
|
| 138 |
+
```text
|
| 139 |
+
WORKER_BASE_URL=https://your-space.hf.space
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
The Vercel site logs into the worker with the same access code and sends large PDF uploads directly to the worker. That keeps 100 MB+ PDFs out of Vercel Functions.
|
| 143 |
+
|
| 144 |
+
If the Vercel site is already unlocked but the worker is not, the app asks for the code once more. After that, both the Vercel site and the worker keep their own signed cookies.
|
| 145 |
+
|
| 146 |
+
Verify the worker before using it:
|
| 147 |
+
|
| 148 |
+
```powershell
|
| 149 |
+
python scripts\check_deployment_readiness.py
|
| 150 |
+
python scripts\validate_deployment_env.py --vercel-env outputs\vercel-production.env --worker-env outputs\worker-secrets.env --worker-url https://your-space.hf.space --origin https://your-vercel-app.vercel.app
|
| 151 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors
|
| 152 |
+
python scripts\prove_live_deployment.py https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234 --smoke-ocr-engine arabic-max
|
| 153 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-upload --smoke-ocr-engine arabic-max
|
| 154 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-scanned --smoke-ocr-engine arabic-max
|
| 155 |
+
python scripts\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max --json > outputs\worker-verification.json
|
| 156 |
+
python scripts\audit_goal_readiness.py --worker-report outputs\worker-verification.json
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
`prove_live_deployment.py` also writes `outputs\live-deployment-proof.json`; treat the live deployment as proven only when that file reports `"complete": true`.
|
| 160 |
+
|
| 161 |
+
`check_deployment_readiness.py` verifies the repo packaging before deployment: required files, Vercel rewrites, no `functions`/`builds` conflict, Dockerfile copy sources, key worker packages, optional Arabic OCR sidecars, and ignore-file safety. `verify_worker.py` checks the live deployed worker after deployment, including upload limit, free storage headroom, Arabic OCR readiness, voice readiness, recommended stack metadata, and CORS. Add `--smoke-upload` when you want to prove the embedded-text path: upload PDF, poll job status, create audio, confirm usable text, check audio/download URLs, and verify audio file signatures. Add `--smoke-scanned` when you want to prove the OCR path with an image-only Arabic PDF and confirm it did not silently use embedded text. Use `--smoke-ocr-engine arabic-max` for final proof; use `--smoke-ocr-engine tesseract` only for a quick fallback-only sanity check.
|
| 162 |
+
|
| 163 |
+
Free CPU Spaces sleep when idle. That is acceptable for a personal/family tool, but first jobs after sleep may be slow. Hugging Face's current free CPU Basic Space default is 2 vCPU, 16 GB RAM, and 50 GB non-persistent disk, so generated audio should remain short-lived unless you add persistent/object storage.
|
| 164 |
+
|
| 165 |
+
For best SILMA pronunciation on Linux workers, you can build/setup with full normalization dependencies:
|
| 166 |
+
|
| 167 |
+
```text
|
| 168 |
+
SILMA_FULL_NORMALIZER_DEPS=1
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
Then set:
|
| 172 |
+
|
| 173 |
+
```text
|
| 174 |
+
SILMA_ENABLE_NORMALIZER=1
|
| 175 |
+
SILMA_FORCE_TASHKEEL=1
|
| 176 |
+
SILMA_NORMALIZE_NUMBERS=1
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
For an optional newer Arabic voice, install Habibi-TTS on the worker with `scripts/setup_habibi.sh` and select `Habibi Arabic MSA` in the website. Keep this optional because Habibi's MSA specialized model is Apache-2.0, while some unified/dialect models have non-commercial license limits. For a faster CPU comparison voice, install Supertonic 3 with `scripts/setup_supertonic.sh` or build with `INSTALL_SUPERTONIC=1`, then select `Supertonic Arabic CPU`.
|
| 180 |
+
|
| 181 |
+
Leave those three values at `0` for the default Docker worker, which uses lightweight stubs for reliability.
|
| 182 |
+
|
| 183 |
+
## Current Best Recommendation
|
| 184 |
+
|
| 185 |
+
For the father's production site:
|
| 186 |
+
|
| 187 |
+
1. Deploy the simple site on Vercel.
|
| 188 |
+
2. Deploy the Docker worker on Hugging Face Spaces CPU as the free trial path.
|
| 189 |
+
3. If cold starts are too frustrating, move only the worker to a cheap always-on CPU host or a family computer.
|
| 190 |
+
4. Keep using `scripts/prepare_book_workflow.py` per book to select OCR settings and, when useful, compare voices from the cleaned OCR sample.
|
| 191 |
+
5. Verify the winning OCR settings with `scripts/dry_run_pdf.py --from-extraction <benchmark-label>` before creating full-book audio.
|
| 192 |
+
6. Try QARI-OCR when the lighter stack fails a representative 5-page Arabic sample and the worker has enough RAM/GPU.
|
| 193 |
+
7. Try Surya or PaddleOCR-VL only for difficult scanned books and only on a worker that can handle the heavier model.
|
| 194 |
+
8. Keep DIMI Arabic OCR v2, Loay Arabic-OCR-Qwen2.5-VL-7B, AtlasOCR, Ketaba-OCR, oi-OCR, Falcon-OCR, and Raqim-style correction on the research watchlist unless a real benchmark shows one beats the wired Arabic OCR stack on the target book. Tawkeed OCR, Arabic-GLM-OCR-v2, Arabic-Qwen3.5-OCR-v4, and Baseer are now wired optional sidecars, but still need the same short-book benchmark before full-book use.
|
| 195 |
+
|
| 196 |
+
Example:
|
| 197 |
+
|
| 198 |
+
```powershell
|
| 199 |
+
python scripts\prepare_book_workflow.py C:\path\to\arabic-book.pdf --sample-pages 1 --engines easyocr paddleocr tesseract --verify-audio --benchmark-voices --write-env outputs\recommended-ocr.env --write-report outputs\book-prep-report.md
|
| 200 |
+
python scripts\benchmark_ocr.py C:\path\to\arabic-book.pdf --page-limit 1 --engines best
|
| 201 |
+
python scripts\dry_run_pdf.py C:\path\to\arabic-book.pdf --from-extraction best:tesseract@2x-psm4
|
| 202 |
+
python scripts\verify_pipeline.py --pdf C:\path\to\arabic-book.pdf --from-extraction best:tesseract@2x-psm4 --voice-id espeak-ar-clear --out outputs\book-smoke.wav
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
The first command is the one-step path. With `--verify-audio`, it prefers the local SILMA voice when installed, then tries Habibi/Supertonic where available, then falls back to eSpeak NG, and synthesizes only a short cleaned sample by default so you can judge pronunciation without creating a full-page audio file. With `--benchmark-voices`, it writes short comparison files for the selected voices using the same cleaned OCR sample. With `--write-env`, it saves only the recommended OCR settings, not secrets; dry-run, external TTS sample export, and pipeline verification can read that snippet with `--env-file`. With `--write-report`, it saves a readable Markdown report for the book. Its JSON output includes `estimateFullBook`, which uses the sample to estimate OCR time, speech characters, and TTS time, `commands` with copy-pasteable follow-up commands including `export_tts_sample.py` for Mishkala/Tashkeel-350M preprocessing, 3arab/Supertonic/MOSS/OmniVoice/Arabic-LoRA/Lahgtna/NAMAA Saudi/Chatterbox/Chatterbox ONNX/tts-arabic-onnx/Spark/Sofelia/VoxCPM and personal/non-commercial Voxtral comparisons, `export_ocr_sample_images.py` for DIMI/Loay-Qwen2.5/Atlas/Ketaba/oi-OCR/Falcon OCR comparisons plus wired sidecar sample scoring when needed, and `score_external_ocr.py` for scoring external OCR text with the app's Arabic speech-readiness metrics, plus `nextSteps` for what to do before the full book run. The following three commands are the manual equivalent when you want to inspect each stage. If the dry run reports `quality=good` and `readyForTts=true`, and the pipeline smoke test creates listenable audio, copy the benchmark's recommended environment values into the worker before processing the full book.
|
| 206 |
+
|
| 207 |
+
## Source Links
|
| 208 |
+
|
| 209 |
+
- Vercel function limits: https://vercel.com/docs/functions/limitations
|
| 210 |
+
- Render free services: https://render.com/free
|
| 211 |
+
- Hugging Face Spaces overview: https://huggingface.co/docs/hub/spaces-overview
|
| 212 |
+
- Hugging Face Spaces GPU/free hardware notes: https://huggingface.co/docs/hub/spaces-gpus
|
| 213 |
+
- Fly.io pricing and legacy free allowances: https://fly.io/docs/about/pricing
|
| 214 |
+
- QARI-OCR 0.4 model: https://huggingface.co/NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct
|
| 215 |
+
- QARI-OCR v0.3 lighter model: https://huggingface.co/NAMAA-Space/Qari-OCR-v0.3-VL-2B-Instruct
|
| 216 |
+
- KATIB 0.8B Arabic OCR model: https://huggingface.co/oddadmix/Katib-Qwen3.5-0.8B-0.1
|
| 217 |
+
- Arabic-Qwen3.5-OCR-v4: https://huggingface.co/sherif1313/Arabic-Qwen3.5-OCR-v4
|
| 218 |
+
- Ketaba-OCR LoRA: https://huggingface.co/HassanB4/Ketaba-OCR-LoRA
|
| 219 |
+
- Tawkeed OCR: https://huggingface.co/tawkeed-sa/tawkeed-ocr
|
| 220 |
+
- Arabic-GLM-OCR-v2: https://huggingface.co/sherif1313/Arabic-GLM-OCR-v2
|
| 221 |
+
- DIMI Arabic OCR v2: https://huggingface.co/AhmedZaky1/DIMI-Arabic-OCR-V2
|
| 222 |
+
- Loay Arabic-OCR-Qwen2.5-VL-7B: https://huggingface.co/loay/Arabic-OCR-Qwen2.5-VL-7B-Vision
|
| 223 |
+
- PaddleOCR-VL-1.6 model: https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6
|
| 224 |
+
- PaddleOCR-VL-1.6 paper: https://arxiv.org/abs/2606.03264
|
docs/recommended-decision-card.json
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"title": "Recommended Free Arabic PDF To Audio Decision Card",
|
| 3 |
+
"currentDefault": {
|
| 4 |
+
"pdf": "PyMuPDF embedded text first",
|
| 5 |
+
"scannedPdfOcr": "OCR_ENGINE=arabic-max",
|
| 6 |
+
"voice": "SILMA TTS (silma-local)",
|
| 7 |
+
"audioStorage": "worker-local retained downloads",
|
| 8 |
+
"hosting": "Vercel shell plus Docker worker via WORKER_BASE_URL"
|
| 9 |
+
},
|
| 10 |
+
"whyThisDefault": [
|
| 11 |
+
"Uses free/local software by default instead of paid cloud TTS.",
|
| 12 |
+
"Avoids OCR when embedded Arabic text already exists.",
|
| 13 |
+
"Uses Arabic-trained OCR sidecars when available and keeps Tesseract/PaddleOCR fallbacks.",
|
| 14 |
+
"Uses an Arabic-focused Apache-2.0 voice first, with eSpeak NG only as a fallback.",
|
| 15 |
+
"Keeps large PDF and audio work off Vercel Functions by delegating to the worker."
|
| 16 |
+
],
|
| 17 |
+
"installFirstOnStrongerWorker": [
|
| 18 |
+
{
|
| 19 |
+
"name": "QARI-OCR 0.4",
|
| 20 |
+
"kind": "ocr",
|
| 21 |
+
"license": "Apache-2.0",
|
| 22 |
+
"why": "Directly trained for Arabic OCR on Islamic books and Arabic manuscripts.",
|
| 23 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_QARI_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/katib-ocr/paddleocr/tesseract on the 5-page sample."
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "PaddleOCR-VL-1.6",
|
| 27 |
+
"kind": "ocr",
|
| 28 |
+
"license": "Apache-2.0",
|
| 29 |
+
"why": "Fresh Apache-2.0 PaddleOCR document parser release with a June 2026 paper signal; the model card claims SOTA document parsing/text performance and the license file is Apache-2.0, but Arabic-book quality still needs same-page scoring.",
|
| 30 |
+
"nextStep": "Build with INSTALL_PADDLEOCR_VL=1 only after the smaller Arabic OCR stack is not clean enough, then benchmark the same 5-page Arabic sample before any full-book run."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"name": "KATIB 0.8B",
|
| 34 |
+
"kind": "ocr",
|
| 35 |
+
"license": "Apache-2.0",
|
| 36 |
+
"why": "Fine-tuned specifically for Arabic OCR, including printed and handwritten text, while being much smaller than QARI 4B.",
|
| 37 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_KATIB_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample."
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "Arabic-GLM-OCR-v2",
|
| 41 |
+
"kind": "ocr",
|
| 42 |
+
"license": "Apache-2.0",
|
| 43 |
+
"why": "Recent Arabic OCR model card claims strong Arabic document extraction and noise reduction; it is wired as an optional sidecar so it can be scored against QARI/KATIB/Arabic-Qwen/Baseer on the target book pages.",
|
| 44 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_ARABIC_GLM_OCR=1, then benchmark it on the same 5-page sample before any full-book run."
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"name": "Arabic-Qwen3.5-OCR-v4",
|
| 48 |
+
"kind": "ocr",
|
| 49 |
+
"license": "Apache-2.0",
|
| 50 |
+
"why": "Recent Arabic OCR model card claims Arabic printed, handwritten, classical, and diacritic handling in a smaller 0.9B model.",
|
| 51 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_ARABIC_QWEN_OCR=1, then benchmark against arabic-max/arabic/katib-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample."
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"name": "Tawkeed OCR",
|
| 55 |
+
"kind": "ocr",
|
| 56 |
+
"license": "Apache-2.0",
|
| 57 |
+
"why": "Arabic-first OCR model forked from QARI-OCR v0.3 and fine-tuned for Arabic documents, handwriting, and scene text; useful to test when QARI 0.4 is too heavy or when edge-style deployment matters.",
|
| 58 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_TAWKEED_OCR=1, then benchmark against QARI 0.4, KATIB, Arabic-Qwen, Baseer, and Tesseract on the same 5-page sample."
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"name": "Baseer OCR V1.0",
|
| 62 |
+
"kind": "ocr",
|
| 63 |
+
"license": "Apache-2.0",
|
| 64 |
+
"why": "Arabic-specific VLM OCR for complex legal documents, multi-column layouts, stamps, tables, and handwritten/printed Arabic.",
|
| 65 |
+
"nextStep": "Install the sidecar or build the worker with INSTALL_BASEER_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/katib-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample."
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"name": "Habibi-TTS MSA",
|
| 69 |
+
"kind": "tts",
|
| 70 |
+
"license": "Apache-2.0 for specialized MSA; other variants may be non-commercial",
|
| 71 |
+
"why": "Arabic-specific 2026 TTS family worth comparing against SILMA on MSA passages.",
|
| 72 |
+
"nextStep": "Install the optional sidecar and listen against the same cleaned OCR sample."
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"name": "Supertonic 3",
|
| 76 |
+
"kind": "tts",
|
| 77 |
+
"license": "OpenRAIL model, MIT sample code",
|
| 78 |
+
"why": "Supertonic 3 supports Arabic, runs locally with ONNX on CPU, and is much smaller than GPU-class multilingual voices, making it a practical free benchmark voice for long-book workers.",
|
| 79 |
+
"nextStep": "Install the sidecar with scripts/setup_supertonic.ps1 or build with INSTALL_SUPERTONIC=1, then benchmark it against SILMA/Habibi on the same cleaned Arabic text."
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"benchmarkBeforePromoting": {
|
| 83 |
+
"ocrCount": 33,
|
| 84 |
+
"ttsCount": 32,
|
| 85 |
+
"ocrFirst": [
|
| 86 |
+
"QARI-OCR 0.4 GGUF",
|
| 87 |
+
"oi-OCR",
|
| 88 |
+
"NuExtract3",
|
| 89 |
+
"Qianfan-OCR",
|
| 90 |
+
"Chandra OCR 2",
|
| 91 |
+
"dots.ocr",
|
| 92 |
+
"olmOCR Arabic LoRA v2",
|
| 93 |
+
"Arabic Large Nougat",
|
| 94 |
+
"DocTR Arabic FAST/PARSEQ",
|
| 95 |
+
"Kraken/eScriptorium Arabic script",
|
| 96 |
+
"Kairawan/Qalamus manuscript OCR",
|
| 97 |
+
"GLM-OCR Arabic/French documents"
|
| 98 |
+
],
|
| 99 |
+
"ttsFirst": [
|
| 100 |
+
"Mishkala Tashkeel",
|
| 101 |
+
"Tashkeel-350M",
|
| 102 |
+
"Mushkil",
|
| 103 |
+
"Thaka KSAA-2026 speech diacritization",
|
| 104 |
+
"3arab-TTS 500M",
|
| 105 |
+
"KaniTTS Arabic",
|
| 106 |
+
"Emirati VITS Male",
|
| 107 |
+
"VoxCPM2",
|
| 108 |
+
"Voxtral TTS",
|
| 109 |
+
"OmniVoice",
|
| 110 |
+
"OmniVoice Arabic LoRA",
|
| 111 |
+
"Arabic-text-to-speech OmniVoice",
|
| 112 |
+
"Lahgtna OmniVoice v2",
|
| 113 |
+
"TADA multilingual TTS",
|
| 114 |
+
"Lahgtna Chatterbox",
|
| 115 |
+
"NAMAA-Saudi-TTS"
|
| 116 |
+
],
|
| 117 |
+
"voicePriority": "Use SILMA first, then benchmark Habibi MSA and OmniVoice on stronger hardware. Keep KaniTTS benchmark-only until its lfm1.0 Hugging Face license metadata is reconciled with the model-card Apache-2.0 text."
|
| 118 |
+
},
|
| 119 |
+
"promotionGate": {
|
| 120 |
+
"sameSample": "same selected Arabic pages or same cleaned Arabic TTS text",
|
| 121 |
+
"scoreJsonRequired": true,
|
| 122 |
+
"tool": "scripts\\model_promotion_gate.py",
|
| 123 |
+
"requires": [
|
| 124 |
+
"candidate beats the current stack",
|
| 125 |
+
"license is acceptable for the deployment",
|
| 126 |
+
"worker runtime and memory are acceptable",
|
| 127 |
+
"privacy/API terms are clear",
|
| 128 |
+
"human Arabic review confirms faithful text/audio"
|
| 129 |
+
]
|
| 130 |
+
},
|
| 131 |
+
"commands": {
|
| 132 |
+
"refreshResearch": "python scripts\\refresh_research_evidence.py --watchlist-report docs\\research-watchlist.md --recommendation-report docs\\recommended-free-stack.md",
|
| 133 |
+
"sourceAudit": "python scripts\\check_research_sources.py --json",
|
| 134 |
+
"prepareBook": "python scripts\\prepare_book_workflow.py C:\\path\\to\\arabic-book.pdf --sample-pages 1 --engine-preset balanced --verify-audio --write-env outputs\\recommended-ocr.env --write-report outputs\\book-prep-report.md",
|
| 135 |
+
"externalOcrSample": "python scripts\\export_ocr_sample_images.py C:\\path\\to\\arabic-book.pdf --out-dir outputs\\external-ocr-sample",
|
| 136 |
+
"externalTtsSample": "python scripts\\export_tts_sample.py C:\\path\\to\\arabic-book.pdf --env-file outputs\\recommended-ocr.env --out-dir outputs\\external-tts-sample"
|
| 137 |
+
}
|
| 138 |
+
}
|
docs/recommended-decision-card.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Recommended Free Arabic PDF To Audio Decision Card
|
| 2 |
+
|
| 3 |
+
## Current Default
|
| 4 |
+
|
| 5 |
+
| Layer | Choice |
|
| 6 |
+
| --- | --- |
|
| 7 |
+
| pdf | PyMuPDF embedded text first |
|
| 8 |
+
| scannedPdfOcr | OCR_ENGINE=arabic-max |
|
| 9 |
+
| voice | SILMA TTS (silma-local) |
|
| 10 |
+
| audioStorage | worker-local retained downloads |
|
| 11 |
+
| hosting | Vercel shell plus Docker worker via WORKER_BASE_URL |
|
| 12 |
+
|
| 13 |
+
## Why
|
| 14 |
+
|
| 15 |
+
- Uses free/local software by default instead of paid cloud TTS.
|
| 16 |
+
- Avoids OCR when embedded Arabic text already exists.
|
| 17 |
+
- Uses Arabic-trained OCR sidecars when available and keeps Tesseract/PaddleOCR fallbacks.
|
| 18 |
+
- Uses an Arabic-focused Apache-2.0 voice first, with eSpeak NG only as a fallback.
|
| 19 |
+
- Keeps large PDF and audio work off Vercel Functions by delegating to the worker.
|
| 20 |
+
|
| 21 |
+
## Benchmark Queue
|
| 22 |
+
|
| 23 |
+
OCR benchmark candidates tracked: 33
|
| 24 |
+
TTS benchmark candidates tracked: 32
|
| 25 |
+
|
| 26 |
+
OCR first lane: QARI-OCR 0.4 GGUF, oi-OCR, NuExtract3, Qianfan-OCR, Chandra OCR 2, dots.ocr, olmOCR Arabic LoRA v2, Arabic Large Nougat, DocTR Arabic FAST/PARSEQ, Kraken/eScriptorium Arabic script, Kairawan/Qalamus manuscript OCR, GLM-OCR Arabic/French documents
|
| 27 |
+
TTS first lane: Mishkala Tashkeel, Tashkeel-350M, Mushkil, Thaka KSAA-2026 speech diacritization, 3arab-TTS 500M, KaniTTS Arabic, Emirati VITS Male, VoxCPM2, Voxtral TTS, OmniVoice, OmniVoice Arabic LoRA, Arabic-text-to-speech OmniVoice, Lahgtna OmniVoice v2, TADA multilingual TTS, Lahgtna Chatterbox, NAMAA-Saudi-TTS
|
| 28 |
+
|
| 29 |
+
## Promotion Gate
|
| 30 |
+
|
| 31 |
+
Tool: `scripts\model_promotion_gate.py`
|
| 32 |
+
Same sample: same selected Arabic pages or same cleaned Arabic TTS text
|
| 33 |
+
|
| 34 |
+
- candidate beats the current stack
|
| 35 |
+
- license is acceptable for the deployment
|
| 36 |
+
- worker runtime and memory are acceptable
|
| 37 |
+
- privacy/API terms are clear
|
| 38 |
+
- human Arabic review confirms faithful text/audio
|
| 39 |
+
|
| 40 |
+
## Commands
|
| 41 |
+
|
| 42 |
+
refreshResearch:
|
| 43 |
+
|
| 44 |
+
```powershell
|
| 45 |
+
python scripts\refresh_research_evidence.py --watchlist-report docs\research-watchlist.md --recommendation-report docs\recommended-free-stack.md
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
sourceAudit:
|
| 49 |
+
|
| 50 |
+
```powershell
|
| 51 |
+
python scripts\check_research_sources.py --json
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
prepareBook:
|
| 55 |
+
|
| 56 |
+
```powershell
|
| 57 |
+
python scripts\prepare_book_workflow.py C:\path\to\arabic-book.pdf --sample-pages 1 --engine-preset balanced --verify-audio --write-env outputs\recommended-ocr.env --write-report outputs\book-prep-report.md
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
externalOcrSample:
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
python scripts\export_ocr_sample_images.py C:\path\to\arabic-book.pdf --out-dir outputs\external-ocr-sample
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
externalTtsSample:
|
| 67 |
+
|
| 68 |
+
```powershell
|
| 69 |
+
python scripts\export_tts_sample.py C:\path\to\arabic-book.pdf --env-file outputs\recommended-ocr.env --out-dir outputs\external-tts-sample
|
| 70 |
+
```
|
docs/recommended-free-stack.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Recommended Free Arabic PDF To Audio Stack
|
| 2 |
+
|
| 3 |
+
This is the compact decision report generated from the current research watchlist.
|
| 4 |
+
|
| 5 |
+
## Use Now
|
| 6 |
+
|
| 7 |
+
| Layer | Recommendation | Why |
|
| 8 |
+
| --- | --- | --- |
|
| 9 |
+
| Embedded PDFs | PyMuPDF text extraction first | It is free, fast, and avoids OCR errors when the PDF already contains usable Arabic text. |
|
| 10 |
+
| Scanned PDFs | `OCR_ENGINE=arabic-max` | It compares the installed Arabic-capable OCR paths and keeps the cleanest speech-ready text. |
|
| 11 |
+
| Default voice | SILMA TTS | Arabic-focused Fusha/MSA voice with normalization and tashkeel options. |
|
| 12 |
+
| Download/storage | Worker-local retained audio files | Free by default and avoids Vercel's 4.5 MB function payload limit; Hugging Face free CPU disk is 50 GB but non-persistent, so downloads are short-lived. |
|
| 13 |
+
| Hosted shape | Vercel shell plus Docker worker via `WORKER_BASE_URL` | Vercel serves the easy website while the worker handles large PDFs, OCR, and TTS on free CPU Space hardware when the job size is reasonable. |
|
| 14 |
+
|
| 15 |
+
## Install First On A Stronger Worker
|
| 16 |
+
|
| 17 |
+
| Candidate | Type | Why | Next Step |
|
| 18 |
+
| --- | --- | --- | --- |
|
| 19 |
+
| QARI-OCR 0.4 | ocr | Directly trained for Arabic OCR on Islamic books and Arabic manuscripts. | Install the sidecar or build the worker with INSTALL_QARI_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/katib-ocr/paddleocr/tesseract on the 5-page sample. |
|
| 20 |
+
| PaddleOCR-VL-1.6 | ocr | Fresh Apache-2.0 PaddleOCR document parser release with a June 2026 paper signal; the model card claims SOTA document parsing/text performance and the license file is Apache-2.0, but Arabic-book quality still needs same-page scoring. | Build with INSTALL_PADDLEOCR_VL=1 only after the smaller Arabic OCR stack is not clean enough, then benchmark the same 5-page Arabic sample before any full-book run. |
|
| 21 |
+
| KATIB 0.8B | ocr | Fine-tuned specifically for Arabic OCR, including printed and handwritten text, while being much smaller than QARI 4B. | Install the sidecar or build the worker with INSTALL_KATIB_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample. |
|
| 22 |
+
| Arabic-GLM-OCR-v2 | ocr | Recent Arabic OCR model card claims strong Arabic document extraction and noise reduction; it is wired as an optional sidecar so it can be scored against QARI/KATIB/Arabic-Qwen/Baseer on the target book pages. | Install the sidecar or build the worker with INSTALL_ARABIC_GLM_OCR=1, then benchmark it on the same 5-page sample before any full-book run. |
|
| 23 |
+
| Arabic-Qwen3.5-OCR-v4 | ocr | Recent Arabic OCR model card claims Arabic printed, handwritten, classical, and diacritic handling in a smaller 0.9B model. | Install the sidecar or build the worker with INSTALL_ARABIC_QWEN_OCR=1, then benchmark against arabic-max/arabic/katib-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample. |
|
| 24 |
+
| Tawkeed OCR | ocr | Arabic-first OCR model forked from QARI-OCR v0.3 and fine-tuned for Arabic documents, handwriting, and scene text; useful to test when QARI 0.4 is too heavy or when edge-style deployment matters. | Install the sidecar or build the worker with INSTALL_TAWKEED_OCR=1, then benchmark against QARI 0.4, KATIB, Arabic-Qwen, Baseer, and Tesseract on the same 5-page sample. |
|
| 25 |
+
| Baseer OCR V1.0 | ocr | Arabic-specific VLM OCR for complex legal documents, multi-column layouts, stamps, tables, and handwritten/printed Arabic. | Install the sidecar or build the worker with INSTALL_BASEER_OCR=1, then benchmark against arabic-max/arabic/arabic-qwen-ocr/katib-ocr/qari-ocr/paddleocr/tesseract on the 5-page sample. |
|
| 26 |
+
| Habibi-TTS MSA | tts | Arabic-specific 2026 TTS family worth comparing against SILMA on MSA passages. | Install the optional sidecar and listen against the same cleaned OCR sample. |
|
| 27 |
+
| Supertonic 3 | tts | Supertonic 3 supports Arabic, runs locally with ONNX on CPU, and is much smaller than GPU-class multilingual voices, making it a practical free benchmark voice for long-book workers. | Install the sidecar with scripts/setup_supertonic.ps1 or build with INSTALL_SUPERTONIC=1, then benchmark it against SILMA/Habibi on the same cleaned Arabic text. |
|
| 28 |
+
|
| 29 |
+
## Benchmark Before Promoting
|
| 30 |
+
|
| 31 |
+
These are promising free/open candidates, but they should not replace the default stack until they win on the same 5-page Arabic sample and same cleaned TTS text.
|
| 32 |
+
|
| 33 |
+
### OCR
|
| 34 |
+
|
| 35 |
+
| Candidate | License | Why It Stays Benchmark-Only |
|
| 36 |
+
| --- | --- | --- |
|
| 37 |
+
| QARI-OCR 0.4 GGUF | Apache-2.0 via QARI 0.4 model card; confirm GGUF packaging metadata before production | Benchmark the GGUF package externally on the same exported Arabic pages against the wired QARI sidecar, KATIB, Arabic-Qwen, Baseer, PaddleOCR, and Tesseract before considering a llama.cpp-style worker path. |
|
| 38 |
+
| oi-OCR | Apache-2.0 | Export the same selected Arabic page images and compare its Markdown/text output against QARI/KATIB/Arabic-Qwen/PaddleOCR/Tesseract before considering any wiring. |
|
| 39 |
+
| NuExtract3 | Apache-2.0 | Use document-to-Markdown/content mode on the exported page images and score the resulting Arabic text against QARI/KATIB/Arabic-Qwen/Baseer/PaddleOCR/Tesseract before promotion. |
|
| 40 |
+
| Qianfan-OCR | Apache-2.0 | Benchmark externally only after QARI/KATIB/Arabic-Qwen/Baseer/PaddleOCR are not clean enough; score it on the same exported Arabic book pages before considering any worker wiring. |
|
| 41 |
+
| Chandra OCR 2 | Apache-2.0 code; modified OpenRAIL-M model weights | Benchmark externally on the same exported page images for hard layouts, tables, forms, or mixed-language pages; keep QARI/KATIB/Arabic-Qwen/Baseer first for Arabic books unless Chandra wins same-page scoring and the license/runtime fit. |
|
| 42 |
+
| dots.ocr | MIT | Run externally on the same exported Arabic page images and score the resulting text against QARI/KATIB/Arabic-Qwen/Baseer/PaddleOCR/Tesseract before considering any worker wiring. |
|
| 43 |
+
| olmOCR Arabic LoRA v2 | Apache-2.0 adapter; confirm base model license/runtime before production | Run externally on the same exported full-page manuscript images and compare against Ketaba, QARI, HAFITH/Glimpse line workflows, Kraken/eScriptorium, and the wired Arabic OCR baseline before considering any sidecar work. |
|
| 44 |
+
| Arabic Large Nougat | GPL-3.0 | Run externally on the same exported Arabic book page images and compare Markdown/text output against QARI, KATIB, Arabic-Qwen, Baseer, PaddleOCR, Tesseract, and the other external OCR benchmarks before considering any separate license-aware workflow. |
|
| 45 |
+
| DocTR Arabic FAST/PARSEQ | Apache-2.0 detector; recognition card lacks clear metadata, confirm before production | Benchmark externally on the same exported Arabic page images and promote only if the recognition model license is confirmed and it beats PaddleOCR/Tesseract/EasyOCR on book text ordering and word preservation. |
|
| 46 |
+
| Kraken/eScriptorium Arabic script | Apache-2.0 engine; model license depends on selected Kraken model | Export the same selected page images, run Kraken/eScriptorium with an Arabic-script recognition model or line-cropped workflow, then score the resulting text against the wired Arabic OCR stack before considering any sidecar work. |
|
| 47 |
+
| Kairawan/Qalamus manuscript OCR | free web service; engine/package license not established | Use only as an external comparison when the source PDF is manuscript-like; do not wire it into the app unless a reusable open engine, API terms, privacy story, and same-page scoring beat QARI/KATIB/Kraken/HAFITH on the selected sample. |
|
| 48 |
+
| GLM-OCR Arabic/French documents | check model card/base license before production use | Benchmark externally for administrative/form-like Arabic PDFs and compare against Arabic-GLM-OCR-v2, QARI, KATIB, Baseer, PaddleOCR, and Tesseract before wiring. |
|
| 49 |
+
|
| 50 |
+
### TTS
|
| 51 |
+
|
| 52 |
+
| Candidate | License | Why It Stays Benchmark-Only |
|
| 53 |
+
| --- | --- | --- |
|
| 54 |
+
| Mishkala Tashkeel | Apache-2.0 | Benchmark on the same cleaned speech sample before wiring. Promote only if listening tests improve pronunciation without changing meaning or adding distracting/incorrect harakat. |
|
| 55 |
+
| Tashkeel-350M | Apache-2.0 | Export the same cleaned Arabic TTS sample, create a Tashkeel-350M diacritized copy, synthesize plain/Mishkala/Tashkeel-350M with the same voice, and score meaning preservation plus long-listen comfort. |
|
| 56 |
+
| Mushkil | Apache-2.0 | Export the same cleaned Arabic TTS sample, create a Mushkil-diacritized copy, synthesize plain/Mishkala/Tashkeel-350M/Mushkil with the same voice, and score meaning preservation plus long-listen comfort. |
|
| 57 |
+
| Thaka KSAA-2026 speech diacritization | CC BY 4.0 paper; implementation/model license not established | Track for released code/weights or a permissive checkpoint. Until then, keep website preprocessing limited to same-sample Mishkala/Tashkeel-350M/Mushkil listening tests and meaning-preservation scoring. |
|
| 58 |
+
| 3arab-TTS 500M | Apache-2.0 | Export the same cleaned Arabic text used for SILMA/Habibi, then compare base and VoiceDesign variants for audiobook comfort, stability, and long-form pacing. |
|
| 59 |
+
| KaniTTS Arabic | model card says Apache-2.0, but Hugging Face metadata reports lfm1.0; confirm before production | Export the same cleaned Arabic sample used for SILMA/Habibi, then benchmark naturalness, skipped words, pacing, runtime, and license fit before considering app wiring. |
|
| 60 |
+
| Emirati VITS Male | Apache-2.0 | Benchmark only when the target PDF benefits from Emirati/Gulf pronunciation; keep SILMA/Habibi ahead for MSA books unless listening tests say otherwise. |
|
| 61 |
+
| VoxCPM2 | Apache-2.0 | Benchmark externally with the same cleaned Arabic sample before deciding whether it is worth integrating. |
|
| 62 |
+
| Voxtral TTS | CC-BY-NC-4.0 | Benchmark only as a personal/non-commercial strong-worker comparison using the same cleaned Arabic sample; do not wire it as the default public/free website voice. |
|
| 63 |
+
| OmniVoice | Apache-2.0 | Export the same cleaned Arabic text used for SILMA/Habibi and compare Arabic naturalness, speed, and setup complexity before wiring it into the app. |
|
| 64 |
+
| OmniVoice Arabic LoRA | Apache-2.0 | Benchmark only after the base OmniVoice command is working, using the exact same cleaned Arabic sample and reference audio. |
|
| 65 |
+
| Arabic-text-to-speech OmniVoice | Apache-2.0 | Export the same cleaned Arabic sample used for SILMA/Habibi and compare naturalness, skipped words, repetition, runtime, and setup complexity before any app wiring. |
|
| 66 |
+
| Lahgtna OmniVoice v2 | license not declared on model card | Benchmark externally only when dialect pronunciation matters, confirm licensing before production, and keep SILMA/Habibi ahead for MSA books until listening tests prove otherwise. |
|
| 67 |
+
| TADA multilingual TTS | Llama 3.2 license | Export the same cleaned Arabic sample and benchmark with language='ar' only if the Llama 3.2 license is acceptable; keep SILMA/Habibi ahead for the permissive default. |
|
| 68 |
+
| Lahgtna Chatterbox | MIT | Export the same cleaned Arabic text and listen for repetition/stability before considering app wiring. |
|
| 69 |
+
| NAMAA-Saudi-TTS | MIT | Benchmark only when Saudi dialect pronunciation fits the target PDF; keep SILMA/Habibi first for MSA books and compare against Saudi Arabic Qwen3-TTS and Emirati voices before wiring. |
|
| 70 |
+
|
| 71 |
+
### Current Voice Priority
|
| 72 |
+
|
| 73 |
+
Use SILMA first for the practical free Arabic audiobook voice. On a stronger worker, benchmark Habibi MSA and OmniVoice next. Keep KaniTTS benchmark-only until the `lfm1.0` Hugging Face license metadata is reconciled with the model-card Apache-2.0 text.
|
| 74 |
+
|
| 75 |
+
## Promotion Rule
|
| 76 |
+
|
| 77 |
+
Promote a model only when all of these are true:
|
| 78 |
+
|
| 79 |
+
1. It is free for the intended personal/family use.
|
| 80 |
+
2. Its license is acceptable for the deployment.
|
| 81 |
+
3. It beats the current stack on the same selected Arabic pages or same cleaned Arabic voice sample.
|
| 82 |
+
4. It preserves Arabic reading order, words, and pronunciation better than the default.
|
| 83 |
+
5. Its runtime is acceptable for the target worker.
|
| 84 |
+
6. The generated JSON score passes `scripts\model_promotion_gate.py` after human review.
|
| 85 |
+
|
| 86 |
+
Current practical default: PyMuPDF -> `arabic-max` OCR -> SILMA TTS -> downloadable worker audio.
|
docs/research-watchlist.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docs/source-evidence.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Source Evidence For The Free Arabic PDF-To-Audio Stack
|
| 2 |
+
|
| 3 |
+
Last checked: June 7, 2026.
|
| 4 |
+
|
| 5 |
+
This file records why each source matters to the current recommendation. It is intentionally short so it can be checked quickly before deployment.
|
| 6 |
+
|
| 7 |
+
See `docs/huggingface-model-metadata.md` for the latest tracked Hugging Face model ID, license, and last-modified snapshot used by this recommendation.
|
| 8 |
+
|
| 9 |
+
## OCR Sources
|
| 10 |
+
|
| 11 |
+
| Source | Link | Evidence Used |
|
| 12 |
+
| --- | --- | --- |
|
| 13 |
+
| EasyOCR | https://github.com/JaidedAI/EasyOCR | Free local OCR engine with Arabic support; useful fallback for older scans and layouts. |
|
| 14 |
+
| PaddleOCR PP-OCRv5 multilingual recognition | https://github.com/PaddlePaddle/PaddleOCR/blob/main/docs/version3.x/algorithm/PP-OCRv5/PP-OCRv5_multi_languages.en.md | Documents Arabic-script recognition model support, including `arabic_PP-OCRv5_mobile_rec`. |
|
| 15 |
+
| PaddleOCR OCR pipeline | https://www.paddleocr.ai/main/en/version3.x/pipeline_usage/OCR.html | Confirms the current PaddleOCR OCR pipeline interface used by the sidecar. |
|
| 16 |
+
| PaddleOCR latest docs | https://www.paddleocr.ai/latest/en/index.html | Tracks current PaddleOCR releases and document parser direction. |
|
| 17 |
+
| PP-OCRv5 paper | https://arxiv.org/abs/2603.24373 | Supports the lightweight OCR default choice before heavy VLM OCR. |
|
| 18 |
+
| QARI-OCR 0.4 model | https://huggingface.co/NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct | Arabic-specific OCR VLM trained for Islamic books and Arabic manuscripts, Apache-2.0, used as the optional strong-worker Arabic OCR upgrade; current model card shows no hosted inference provider, so it needs a local/worker runtime. |
|
| 19 |
+
| QARI-OCR 0.4 GGUF | https://huggingface.co/marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF | Newer GGUF packaging signal for QARI-OCR 0.4; keep benchmark-only until it matches the wired QARI sidecar on the same Arabic pages and its packaging/license metadata is confirmed. |
|
| 20 |
+
| QARI-OCR v0.3 lighter model | https://huggingface.co/NAMAA-Space/Qari-OCR-v0.3-VL-2B-Instruct | Smaller configurable QARI fallback for workers that cannot handle the 4B model. |
|
| 21 |
+
| QARI-OCR paper | https://arxiv.org/abs/2506.02295 | Research background for QARI's Arabic OCR/document understanding role. |
|
| 22 |
+
| KATIB 0.8B Arabic OCR model | https://huggingface.co/oddadmix/Katib-Qwen3.5-0.8B-0.1 | Apache-2.0 Arabic OCR VLM fine-tuned for Arabic printed and handwritten text; wired as the smaller optional Arabic-trained OCR sidecar. |
|
| 23 |
+
| Ketaba-OCR LoRA | https://huggingface.co/HassanB4/Ketaba-OCR-LoRA | Apache-2.0 Arabic manuscript OCR LoRA benchmark candidate; not wired because it needs a separate base VLM plus adapter setup, but worth testing when QARI/KATIB/PaddleOCR struggle. |
|
| 24 |
+
| Qari-OCR-LoRA | https://huggingface.co/HassanB4/Qari-OCR-LoRA | Apache-2.0 experimental QARI-family manuscript OCR LoRA from the NakbaNLP 2026 Arabic manuscript task; keep as a secondary external benchmark after Ketaba because the model card says Ketaba was the primary winning submission. |
|
| 25 |
+
| Tawkeed OCR | https://huggingface.co/tawkeed-sa/tawkeed-ocr | Apache-2.0 Arabic-first OCR model forked from QARI-OCR v0.3 and fine-tuned for Arabic documents, handwriting, and scene text; wired as an optional sidecar for short-sample benchmarks when QARI-OCR 0.4 is too heavy or edge-style Arabic OCR matters. |
|
| 26 |
+
| PaddleOCR-VL-1.6 model | https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6 | Apache-2.0 0.9B document parser model; useful as a strong-worker OCR/layout benchmark after the smaller Arabic-specific stack is tested. |
|
| 27 |
+
| PaddleOCR-VL-1.6 paper | https://arxiv.org/abs/2606.03264 | June 2026 research background for the current PaddleOCR-VL-1.6 document parsing upgrade. |
|
| 28 |
+
| oi-OCR | https://huggingface.co/oi-uae/oi-OCR | Apache-2.0 English/Arabic PDF document parser with April 2026 ParseBench claims; keep as an external structured-document benchmark, not the Arabic-book default. |
|
| 29 |
+
| Falcon-OCR | https://huggingface.co/tiiuae/Falcon-OCR | Apache-2.0 compact 300M document OCR VLM; useful watchlist candidate for strong-worker Arabic benchmarks, not default until it beats the Arabic-specific stack. |
|
| 30 |
+
| Falcon Perception paper | https://arxiv.org/abs/2603.27365 | Research background for Falcon-OCR's early-fusion document OCR design and public benchmark claims. |
|
| 31 |
+
| Baseer OCR model | https://huggingface.co/AbdoTarek/Baseer-OCR-V1.0 | Apache-2.0 Arabic document OCR VLM fine-tuned from Qwen2-VL-2B for complex Arabic legal documents, multi-column layouts, stamps, tables, and handwritten/printed Arabic; wired as an optional strong-worker sidecar. |
|
| 32 |
+
| Baseer OCR paper | https://arxiv.org/abs/2509.18174 | Research background for Baseer as a future Arabic OCR comparison. |
|
| 33 |
+
| Arabic-GLM-OCR-v2 | https://huggingface.co/sherif1313/Arabic-GLM-OCR-v2 | New Apache-2.0 Arabic OCR VLM; wired as an optional sidecar and still benchmarked on short samples before full-book use because claims need independent scoring on the actual book pages. |
|
| 34 |
+
| Arabic-Qwen3.5-OCR-v4 | https://huggingface.co/sherif1313/Arabic-Qwen3.5-OCR-v4 | Recent Apache-2.0 0.9B Arabic OCR VLM for printed, handwritten, classical, and diacritic-heavy Arabic; wired as an optional sidecar and benchmarked before full-book use. |
|
| 35 |
+
| aNS Qwen3-VL Arabic OCR v3 | https://huggingface.co/aNS2024/qwen3-vl-arabic-ocr-v3 | Fresh Qwen3-VL-2B Arabic OCR fine-tune; the public card shows no hosted inference provider and sparse OCR/license evidence, so keep it external until it beats QARI/KATIB/Arabic-Qwen/Baseer on the same selected Arabic pages. |
|
| 36 |
+
| Waraqon v3 Arabic OCR HTML Qari | https://huggingface.co/FatimahEmadEldin/Waraqon-v3-Arabic-OCR-HTML-Qari | Apache-2.0 Qari-family Arabic OCR fine-tune for HTML/structured output; keep external until normalized readable text beats QARI/KATIB/Arabic-Qwen/Baseer on the same pages because audiobook generation needs faithful continuous Arabic text more than raw markup. |
|
| 37 |
+
| DeepSeek-OCR-2 | https://huggingface.co/deepseek-ai/DeepSeek-OCR-2 | Official Apache-2.0 3B DeepSeek OCR successor with 2026 paper/model-card references and public document OCR benchmark results; keep external because it is not Arabic-specific and needs GPU/large-worker inference. |
|
| 38 |
+
| DeepSeek Arabic OCR v6 | https://huggingface.co/melsiddieg/deepseek_ocr_arabic_v6 | Apache-2.0 Arabic-labeled DeepSeek-OCR fine-tune; newer than the v4/v5 Arabic fine-tunes, but keep benchmark-only because the card has sparse Arabic-book evidence and no inference provider deployment. |
|
| 39 |
+
| Loay Arabic-OCR-DeepSeek-OCR-2 | https://huggingface.co/loay/Arabic-OCR-DeepSeek-OCR-2 | Apache-2.0 merged DeepSeek-OCR-2 Arabic fine-tune for high-precision OCR and structural layout analysis; keep benchmark-only until it beats QARI/KATIB/Arabic-Qwen/Baseer on the same selected Arabic book pages. |
|
| 40 |
+
| Arabic-English handwritten OCR Qwen3-VL | https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B | Apache-2.0 Qwen3-VL-4B handwritten Arabic/English OCR watchlist model; keep external because the card says it is research-oriented and not deployed by inference providers. |
|
| 41 |
+
| Arabic-English handwritten OCR v3 | https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-v3 | Apache-2.0 Qwen2.5-VL 3B-class Arabic/English handwritten OCR watchlist model; keep external for handwriting/manuscript-heavy pages because it is large and not deployed by inference providers. |
|
| 42 |
+
| Arabic handwritten OCR 4-bit Qwen2.5-VL | https://huggingface.co/sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3 | Apache-2.0 4-bit Arabic handwritten OCR checkpoint with about 2.44GB of model assets; keep external as a lighter handwriting/manuscript benchmark when the full handwritten model is too heavy. |
|
| 43 |
+
| NAKBA Arabic manuscript line OCR baseline | https://huggingface.co/U4RASD/ar-ms-baseline | NAKBA NLP 2026 Arabic manuscript understanding baseline fine-tuned from Qwen3-VL-8B for line-image transcription; keep external and line-level only until license fit and page-cropping workflow are proven. |
|
| 44 |
+
| HAFITH | https://huggingface.co/mdnaseif/hafith | Apache-2.0 historical Arabic manuscript recognition model with Arabic-native tokenization and 5.10% CER claims; keep external and line-level only because the model card says it requires pre-segmented text lines. |
|
| 45 |
+
| Glimpse RTL OCR | https://huggingface.co/surfiniaburger/unsloth_finetune_ocr_arabic | Apache-2.0 Arabic/Persian RTL text-line OCR model with 6.97% CER claims on unseen RTL text lines; keep external and line-level only until page-cropping workflow and same-book accuracy are proven. |
|
| 46 |
+
| olmOCR Arabic LoRA v2 | https://huggingface.co/hastyle/olmOCR-arabic-lora-v2 | Apache-2.0 Arabic manuscript OCR LoRA for full-page manuscript images; keep external/heavy because it needs the 7B olmOCR base and base license/runtime confirmation. |
|
| 47 |
+
| Arabic OCR Qwen2.5-VL GGUF | https://huggingface.co/mo1998/arabic-ocr-qwen2.5-vl | QariOCR-v0.3-trained Arabic/English OCR fine-tune on a Qwen2.5-VL 7B GGUF/Unsloth path; keep external with license confirmation because it is large and not inference-provider deployed. |
|
| 48 |
+
| Qwen3-VL Persian/Arabic line OCR | https://huggingface.co/mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0 | Apache-2.0 Qwen3-VL 2B Persian/Arabic OCR watchlist model; keep external unless pages are cropped into text lines because the model card says it was trained on individual lines and is not designed for full-page OCR. |
|
| 49 |
+
| DIMI Arabic OCR v2 | https://huggingface.co/AhmedZaky1/DIMI-Arabic-OCR-V2 | Apache-2.0 Arabic OCR LoRA fine-tuned from Qwen2.5-VL-7B; strong external benchmark candidate for printed Arabic and diacritics-heavy pages, but too heavy for the normal free worker default. |
|
| 50 |
+
| Loay Arabic-OCR-Qwen2.5-VL-7B | https://huggingface.co/loay/Arabic-OCR-Qwen2.5-VL-7B-Vision | Arabic OCR VLM fine-tuned from Qwen2.5-VL-7B for Arabic text in images; keep as a high-capacity external benchmark because 7B-class runtime is too heavy for the normal free family-worker default. |
|
| 51 |
+
| AtlasOCR | https://huggingface.co/atlasia/AtlasOCR | First open-source Darija/Moroccan Arabic OCR model; useful only for Darija-specific PDFs and needs license confirmation before production use. |
|
| 52 |
+
| NuExtract3 | https://huggingface.co/numind/NuExtract3 | Apache-2.0 4B multilingual document understanding model for OCR, document-to-Markdown, tables, forms, invoices, contracts, and multi-page PDFs; benchmark externally for complex layouts but keep QARI/KATIB/Arabic-Qwen first for Arabic books. |
|
| 53 |
+
| Qianfan-OCR | https://huggingface.co/baidu/Qianfan-OCR | Apache-2.0 5B multilingual document-intelligence OCR/VLM; benchmark externally only on large workers because it is not Arabic-book-specific and is too heavy for the default free family-worker path. |
|
| 54 |
+
| Chandra OCR 2 | https://github.com/datalab-to/chandra | Recent 4B multilingual OCR/layout model supporting Arabic among 90+ languages, strong for structured Markdown/HTML/JSON extraction, tables, forms, and handwriting; code is Apache-2.0 but weights are modified OpenRAIL-M, so keep it external and benchmark-only. |
|
| 55 |
+
| dots.ocr | https://huggingface.co/rednote-hilab/dots.ocr | MIT compact multilingual document parser that unifies layout detection and content recognition, with reading-order, table, and formula support; benchmark externally for difficult Arabic layouts but keep Arabic-trained OCR first for books. |
|
| 56 |
+
| Arabic Large Nougat | https://huggingface.co/MohamedRashad/arabic-large-nougat | GPL-3.0 Arabic book-page OCR-to-Markdown model; useful as an external benchmark for structured Arabic book text, but keep out of the default public hosted worker because of GPL licensing and hallucination/context-length caveats. |
|
| 57 |
+
| DocTR Arabic FAST/PARSEQ | https://huggingface.co/madskills/doctr-fast_base-arabic and https://huggingface.co/madskills/doctr-parseq-arabic | Apache-2.0 Arabic FAST detector paired with an Arabic PARSEQ recognizer; keep benchmark-only until recognizer licensing and same-page book accuracy are confirmed. |
|
| 58 |
+
| Kraken/eScriptorium Arabic script | https://kraken.re/main/index.html and https://escriptorium.eu/about | Free/open-source ATR/OCR workflow for historical and non-Latin scripts. Benchmark externally for historical Arabic print or manuscript-like PDFs with an Arabic-script Kraken model, but keep it out of the default family-worker path because model choice, line segmentation, and per-book training can matter. |
|
| 59 |
+
| Kairawan/Qalamus manuscript OCR | https://kairawan.org/ | Free 2026 Arabic and Islamic manuscript transcription service signal; useful as an external manuscript benchmark only because the reusable engine/package license, privacy/API terms, and worker integration path are not established. |
|
| 60 |
+
| GLM-OCR Arabic/French documents | https://huggingface.co/maloukafer/GLM-OCR-finetuned-documents | Recent GLM-OCR LoRA fine-tune for Arabic/French administrative and scanned documents; useful for forms or newspapers, not the Arabic-book default. |
|
| 61 |
+
| mimoha Arabic OCR | https://huggingface.co/mimoha/ocr | Apache-2.0 Arabic OCR card that says it extracts Arabic text from images, but the public card is sparse, so keep it low-priority and external. |
|
| 62 |
+
| Raqim post-OCR correction | https://www.sciencedirect.com/science/article/pii/S187705092600058X | 2026 Arabic OCR correction research using dictionary and LLM correction; useful to track, but not wired because automatic correction can alter exact book or religious wording before TTS. |
|
| 63 |
+
| Arabic Legal Documents OCR 1.0 | https://huggingface.co/bakrianoo/arabic-legal-documents-ocr-1.0 | Recent Gemma-licensed Arabic legal/scanned-document OCR VLM; benchmark externally only for legal or form-like PDFs because it is domain-specific and not permissive enough for the default family audiobook stack. |
|
| 64 |
+
| Surya | https://github.com/datalab-to/surya | Heavy OCR/layout path to test only on strong workers. |
|
| 65 |
+
|
| 66 |
+
## TTS Sources
|
| 67 |
+
|
| 68 |
+
| Source | Link | Evidence Used |
|
| 69 |
+
| --- | --- | --- |
|
| 70 |
+
| SILMA TTS | https://huggingface.co/silma-ai/silma-tts | Best free permissive local Arabic voice baseline in this project: Arabic/English, Fusha/MSA, 150M parameters, Arabic normalization/tashkeel support, MIT code, Apache-2.0 model weights; current model card shows no hosted inference provider, so production quality needs the worker path and worker runtime. |
|
| 71 |
+
| SILMA open source Arabic TTS models | https://silma.ai/open-source-arabic-tts-models | Official SILMA page confirming the open-source Arabic/English TTS model, Modern Standard Arabic support, 150M size, accepts text with or without tashkeel, voice cloning, and reported short-text latency. |
|
| 72 |
+
| SILMA Hugging Face launch article | https://huggingface.co/blog/silma-ai/opensource-arabic-english-text-to-speech-model | Primary launch article describing SILMA TTS v1 as a 150M Arabic/English model released under Apache-2.0, with Arabic text handling, chunking, normalization, and robustness improvements. |
|
| 73 |
+
| SILMA Arabic TTS benchmark | https://silma.ai/arabic-tts-benchmark | Confirms Arabic TTS quality still needs side-by-side listening because standard automatic metrics miss Arabic naturalness details. |
|
| 74 |
+
| Habibi-TTS | https://github.com/SWivid/Habibi-TTS | Optional MSA voice comparison path; specialized MSA model is Apache-2.0, while unified/SAU/UAE variants are non-commercial. |
|
| 75 |
+
| Habibi-TTS paper | https://arxiv.org/abs/2601.13802 | 2026 open-source Arabic TTS research source for the multi-dialect Habibi family and benchmark. |
|
| 76 |
+
| Mishkala Tashkeel | https://huggingface.co/flokymind/mishkala | Apache-2.0 lightweight Arabic diacritization model; track as a pronunciation-preprocessor benchmark, not a default, because automatic tashkeel can change perceived meaning or sound distracting if wrong. |
|
| 77 |
+
| Tashkeel-350M | https://huggingface.co/Etherll/Tashkeel-350M | Apache-2.0 350M Arabic diacritization model; benchmark beside Mishkala on the same cleaned TTS sample because better pronunciation must be proven by listening and meaning preservation, not assumed from model size. |
|
| 78 |
+
| Mushkil | https://huggingface.co/riotu-lab/mushkil | Apache-2.0 AraT5V2 Arabic diacritization model; keep as another pronunciation-preprocessor benchmark beside Mishkala and Tashkeel-350M because automatic harakat can help pronunciation but must preserve meaning and listening comfort. |
|
| 79 |
+
| Thaka KSAA-2026 speech diacritization | https://arxiv.org/abs/2605.25928 and https://www.codabench.org/competitions/11859/ | Late-May 2026 KSAA shared-task winning paper for Arabic speech/text diacritization; track as a research signal only because it describes a CATT-Whisper ensemble and benchmark result, not a simple permissive model to deploy in the PDF-to-audio worker. |
|
| 80 |
+
| 3arab-TTS 500M | https://huggingface.co/sherif1313/3arab-TTS-500M-v1 | Apache-2.0 Arabic-only 500M text-to-speech model, updated in late May 2026; benchmark externally against SILMA/Habibi because it is new and not yet proven for long audiobook passages. |
|
| 81 |
+
| 3arab-TTS-500M-v1-VoiceDesign | https://huggingface.co/sherif1313/3arab-TTS-500M-v1-VoiceDesign | Apache-2.0 VoiceDesign variant updated June 2026, with selectable voice styles; use the same cleaned Arabic sample for manual listening tests before app wiring. |
|
| 82 |
+
| KaniTTS Arabic | https://huggingface.co/nineninesix/kani-tts-400m-ar | Arabic-only 400M TTS model with high-speed claims; Hugging Face metadata currently reports `lfm1.0` even though the page text describes Apache-style licensing, so benchmark externally and confirm license fit before app wiring. |
|
| 83 |
+
| Emirati VITS Male | https://huggingface.co/vadimbelsky/emirati-vits-male-1.0 | Apache-2.0 bilingual Emirati Arabic/English VITS voice; useful for Gulf dialect comparison, but keep it benchmark-only for MSA books unless same-text listening tests beat SILMA/Habibi. |
|
| 84 |
+
| VoxCPM2 | https://huggingface.co/openbmb/VoxCPM2 | Apache-2.0 multilingual TTS with Arabic among 30 supported languages, 2B parameters, and 48 kHz output; track as a strong-worker voice benchmark candidate. |
|
| 85 |
+
| VoxCPM paper | https://arxiv.org/abs/2509.24650 | Research background for VoxCPM/VoxCPM2 tokenizer-free multilingual TTS and open Apache-2.0 release. |
|
| 86 |
+
| Voxtral TTS | https://huggingface.co/mistralai/Voxtral-4B-TTS-2603 | Open-weight Mistral TTS model with Arabic among 9 supported languages, but the model card lists `cc-by-nc-4.0` and 4B GPU-oriented deployment, so keep it personal/non-commercial and external. |
|
| 87 |
+
| Voxtral TTS paper | https://arxiv.org/abs/2603.25551 | Research background for Voxtral TTS quality and multilingual voice-cloning claims. |
|
| 88 |
+
| MOSS-TTS-Nano | https://github.com/OpenMOSS/MOSS-TTS-Nano | Apache-2.0 multilingual 0.1B TTS model with Arabic support, packaged CLI, and ONNX CPU path; track as a CPU-friendly voice benchmark candidate before wiring into the app. |
|
| 89 |
+
| Supertonic 3 | https://huggingface.co/Supertone/supertonic-3 | OpenRAIL model with 99M on-device TTS, Arabic support, and ONNX CPU inference; wired as an optional local benchmark voice, not the Arabic-first default. |
|
| 90 |
+
| Kyutai Pocket TTS | https://kyutai.org/tts | Current official page is attractive for CPU real-time TTS, but its listed Pocket TTS languages are English, French, German, Spanish, Portuguese, and Italian, not Arabic, so it is excluded from the Arabic voice candidate list until Arabic support appears. |
|
| 91 |
+
| OmniVoice | https://huggingface.co/k2-fsa/OmniVoice | Apache-2.0 0.6B zero-shot TTS with 646-language coverage, Arabic included, high current usage, and published 2026 OmniVoice evidence; benchmark as the priority permissive strong-worker voice after SILMA/Habibi. |
|
| 92 |
+
| OmniVoice Arabic LoRA | https://huggingface.co/vivooglobal/omnivoice-lora-ar | Apache-2.0 Arabic LoRA adapter for OmniVoice intended to improve Arabic zero-shot voice cloning; benchmark after base OmniVoice works. |
|
| 93 |
+
| Arabic-text-to-speech OmniVoice | https://huggingface.co/bilalRHCH/Arabic-text-to-speech | Apache-2.0 Arabic-labeled OmniVoice packaging with 646-language OmniVoice support and a demo Space signal; keep as a same-sample strong-worker benchmark until it proves long-form MSA audiobook quality against SILMA/Habibi. |
|
| 94 |
+
| Lahgtna OmniVoice v2 | https://huggingface.co/oddadmix/lahgtna-omnivoice-v2 | New Arabic-dialect OmniVoice fine-tune with broad dialect tags and diacritics support; benchmark externally for dialectal content and confirm licensing before production wiring. |
|
| 95 |
+
| TADA multilingual TTS | https://huggingface.co/HumeAI/tada-3b-ml | Free/open-weight multilingual TTS model under the Llama 3.2 license, with Arabic aligner support and text-acoustic alignment to reduce off-script speech; benchmark externally only after checking license fit because it is a 3B-class strong-worker option, not the practical default. |
|
| 96 |
+
| Lahgtna Chatterbox | https://huggingface.co/oddadmix/lahgtna-chatterbox-v1 | MIT Arabic dialect TTS benchmark candidate based on Chatterbox; useful for dialectal speech tests, but the model card notes repetition can occur. |
|
| 97 |
+
| NAMAA-Saudi-TTS | https://huggingface.co/NAMAA-Space/NAMAA-Saudi-TTS | MIT Saudi Arabic Chatterbox Multilingual fine-tune; benchmark only for Saudi/Gulf dialect fit because the card says it targets everyday Saudi speech rather than MSA books. |
|
| 98 |
+
| NAMAA-Saudi-TTS-V2 | https://huggingface.co/NAMAA-Space/NAMAA-Saudi-TTS-V2 | Newer Najdi/Saudi Habibi/F5-TTS voice-cloning fine-tune; do not use as the free default because it is CC-BY-NC-SA-4.0, dialect-specific, reference-audio based, and not deployed by an inference provider. |
|
| 99 |
+
| NAMAA-Egyptian-TTS | https://huggingface.co/NAMAA-Space/NAMAA-Egyptian-TTS | MIT Egyptian Arabic Chatterbox Multilingual fine-tune with local/hosted inference examples and live demo; benchmark only for Egyptian/dialectal text because it targets everyday Egyptian speech, not MSA books, and the card notes number/pronunciation limitations. |
|
| 100 |
+
| Saudi Chatterbox fine-tune | https://huggingface.co/FatimahEmadEldin/saudi-tts-chatterbox-finetuned | Apache-2.0 Saudi Arabic Chatterbox Multilingual fine-tune; compare externally with NAMAA-Saudi-TTS and Saudi Qwen3-TTS for Gulf dialect material. |
|
| 101 |
+
| Saudi TTS | https://huggingface.co/AhmedEladl/saudi-tts | Apache-2.0 high-quality Saudi Arabic dialect TTS candidate; benchmark externally with the same cleaned sample because it is dialect-specific and not a proven MSA audiobook voice. |
|
| 102 |
+
| Egyptian Arabic Chatterbox | https://huggingface.co/AliAbdallah/egyptian-arabic-tts-chatterbox | Apache-2.0 Egyptian Arabic Chatterbox fine-tune with 120 hours of clean Egyptian Arabic data; benchmark only for Egyptian/dialectal text because it is single-speaker, GPU-oriented, and may not perform well on non-Egyptian Arabic. |
|
| 103 |
+
| NileTTS-XTTS | https://huggingface.co/KickItLikeShika/NileTTS-XTTS | Apache-2.0 Egyptian Arabic XTTS fine-tune from the 2026 NileTTS paper; benchmark only for Egyptian/dialectal content because it is optimized for Egyptian Arabic rather than MSA books. |
|
| 104 |
+
| Arabic XTTS-v2 Egyptian fine-tune | https://huggingface.co/Moeeldouma/arabic-tts-xtts-v2 | Recent Arabic XTTS-v2 improvement project with Egyptian speaker fine-tuning and documented same-text comparisons; benchmark only for dialectal content because the XTTS-v2 base uses the Coqui Public Model License and the setup is not the permissive default path. |
|
| 105 |
+
| NileTTS paper | https://arxiv.org/abs/2602.15675 | Research source for the NileTTS dataset/model; reports 38 hours of Egyptian Arabic speech and open resources, making it useful as a dialect benchmark but not a general MSA default. |
|
| 106 |
+
| Chatterbox-Multilingual | https://github.com/resemble-ai/chatterbox | MIT multilingual TTS/voice-cloning candidate that lists Arabic support; benchmark externally on the same cleaned Arabic sample before wiring. |
|
| 107 |
+
| Chatterbox Arabic fine-tune | https://huggingface.co/juliardi/chatterbox-multilingual-finetuned-arabic | MIT Arabic-focused Chatterbox adapter claiming improved Arabic pronunciation, diacritics, MSA support, and common dialect support; benchmark on the same cleaned MSA book passage before considering app wiring. |
|
| 108 |
+
| Chatterbox-Multilingual ONNX | https://huggingface.co/onnx-community/chatterbox-multilingual-ONNX | MIT ONNX packaging for Chatterbox-Multilingual with Arabic support; useful as a CPU/ONNX voice benchmark before wiring. |
|
| 109 |
+
| tts-arabic-onnx | https://huggingface.co/nipponjo/tts-arabic-onnx | Arabic-only ONNX FastPitch/MixerTTS package with speaker, pace, vocoder, and vowelizer options; benchmark as a compact CPU candidate but confirm overall model/repo licensing before production. |
|
| 110 |
+
| Spark-TTS Arabic | https://huggingface.co/azeddinShr/Spark-TTS-Arabic-Complete | Apache-2.0 Spark-TTS Arabic fine-tune on ClArTTS; promising for Classical/MSA tests but requires the Spark-TTS repo, reference workflow, and diacritized input. |
|
| 111 |
+
| Sofelia-TTS | https://huggingface.co/hamdallah/Sofelia-TTS | Apache-2.0 Palestinian Arabic TTS/voice-cloning model; useful for dialect tests, not a default MSA audiobook voice. |
|
| 112 |
+
| Arabic-F5-TTS-v2 | https://huggingface.co/IbrahimSalah/Arabic-F5-TTS-v2 | Arabic MSA voice candidate that is not a default because it is non-commercial and requires fully diacritized text. |
|
| 113 |
+
| Qwen3-TTS 0.6B Base | https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base | Apache-2.0 TTS family, but current official released language list does not include Arabic, so do not promote for this Arabic reader yet. |
|
| 114 |
+
| Qwen3-TTS 1.7B Base | https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base | Apache-2.0 larger base model in the same family; still not promoted by itself for Arabic because the official released language list excludes Arabic. |
|
| 115 |
+
| Egyptian Arabic Qwen3-TTS | https://huggingface.co/itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base | Apache-2.0 Qwen3-TTS fine-tune for Egyptian Arabic; benchmark externally only for Egyptian/dialectal content because it is 1.7B, not inference-provider deployed, and not the MSA default for books. |
|
| 116 |
+
| Saudi Arabic Qwen3-TTS | https://huggingface.co/vadimbelsky/qwen3-TTS-KSA | Apache-2.0 Qwen3-TTS fine-tune for Saudi/KSA Arabic; benchmark externally for Saudi/Gulf dialect fit, not as the MSA default. |
|
| 117 |
+
| Emirati Qwen3.5-TTS | https://huggingface.co/vadimbelsky/qwen3.5-TTS-Emirati | Apache-2.0 Qwen3-TTS-family fine-tune for Emirati Arabic; benchmark beside Emirati VITS Male when Gulf pronunciation matters. |
|
| 118 |
+
| Qwen3-TTS technical report | https://arxiv.org/abs/2601.15621 | Confirms the Qwen3-TTS release and Apache-2.0 status, while the official model card remains the source for released language coverage. |
|
| 119 |
+
| MMS Arabic TTS | https://huggingface.co/facebook/mms-tts-ara | Useful hosted fallback for testing, but non-commercial licensing keeps it out of the permissive local default. |
|
| 120 |
+
|
| 121 |
+
## Hosting Sources
|
| 122 |
+
|
| 123 |
+
| Source | Link | Evidence Used |
|
| 124 |
+
| --- | --- | --- |
|
| 125 |
+
| Vercel FastAPI deployment | https://vercel.com/docs/frameworks/backend/fastapi | Vercel website shell deployment shape. |
|
| 126 |
+
| Vercel environment variables | https://vercel.com/docs/environment-variables | Required deployment configuration path. |
|
| 127 |
+
| Vercel Functions limits | https://vercel.com/docs/functions/limitations/ | Python/Node functions are useful for the shell but have finite memory/duration and a 4.5 MB request/response body limit, so 100 MB+ PDFs and generated audio must bypass Vercel Functions. |
|
| 128 |
+
| Vercel Blob usage and pricing | https://vercel.com/docs/vercel-blob/usage-and-pricing | Optional permanent hosted audio storage has a Hobby free allowance, but audio downloads consume storage, operation, transfer, and edge-request quota, so it is not the default free path. |
|
| 129 |
+
| Hugging Face Docker Spaces | https://huggingface.co/docs/hub/main/en/spaces-sdks-docker | Free Docker worker packaging path. |
|
| 130 |
+
| Hugging Face Spaces overview | https://huggingface.co/docs/hub/main/spaces-overview | CPU Basic Spaces are currently free with 2 vCPU, 16 GB RAM, and 50 GB non-persistent disk by default; good for demos/small jobs, but cold starts and ephemeral storage mean audio should be treated as short-lived. |
|
| 131 |
+
| Hugging Face Hub storage limits | https://huggingface.co/docs/hub/main/storage-limits | Confirms generous public Hub storage but a bounded private free tier; useful context for not treating generated private audiobook files as unlimited archival storage. |
|
| 132 |
+
|
| 133 |
+
## Practical Conclusion
|
| 134 |
+
|
| 135 |
+
The current best free practical process is:
|
| 136 |
+
|
| 137 |
+
1. Use PyMuPDF embedded text first.
|
| 138 |
+
2. Use maximum Arabic OCR for scanned pages.
|
| 139 |
+
3. Benchmark a representative 5-page sample before a full book.
|
| 140 |
+
4. Keep QARI-OCR, QARI-OCR 0.4 GGUF, Tawkeed OCR, KATIB, Arabic-Qwen3.5-OCR-v4, aNS Qwen3-VL Arabic OCR v3, Waraqon v3 Arabic OCR HTML Qari, Arabic-GLM-OCR-v2, DeepSeek-OCR-2, DeepSeek Arabic OCR v6, Loay Arabic-OCR-DeepSeek-OCR-2, Arabic-English handwritten OCR Qwen3-VL, Arabic-English handwritten OCR v3, Arabic handwritten OCR 4-bit Qwen2.5-VL, NAKBA Arabic manuscript line OCR baseline, HAFITH, Glimpse RTL OCR, olmOCR Arabic LoRA v2, Arabic OCR Qwen2.5-VL GGUF, Baseer, Ketaba-OCR, Qari-OCR-LoRA, DIMI Arabic OCR v2, Loay Arabic-OCR-Qwen2.5-VL-7B, Arabic Legal Documents OCR 1.0, PaddleOCR-VL, oi-OCR, NuExtract3, Qianfan-OCR, Chandra OCR 2, dots.ocr, Arabic Large Nougat, DocTR Arabic FAST/PARSEQ, Kraken/eScriptorium Arabic script, Kairawan/Qalamus manuscript OCR, GLM-OCR Arabic/French documents, mimoha Arabic OCR, Falcon-OCR, AtlasOCR, and Surya optional for strong workers, external services, or short benchmarks.
|
| 141 |
+
5. Use SILMA as the first local voice to test.
|
| 142 |
+
6. Compare Mishkala, Tashkeel-350M, Mushkil, Habibi MSA, 3arab-TTS 500M, KaniTTS Arabic, Emirati VITS Male, Supertonic 3, MOSS-TTS-Nano, OmniVoice/Arabic LoRA, Arabic-text-to-speech OmniVoice, Lahgtna OmniVoice v2, Lahgtna Chatterbox, NAMAA-Saudi-TTS, NAMAA-Egyptian-TTS, Saudi Chatterbox fine-tune, Saudi TTS, Egyptian Arabic Chatterbox, NileTTS-XTTS, Arabic XTTS-v2 Egyptian fine-tune, Chatterbox-Multilingual, Chatterbox Arabic fine-tune, Chatterbox-Multilingual ONNX, tts-arabic-onnx, Spark-TTS Arabic, Sofelia-TTS, Egyptian Arabic Qwen3-TTS, Saudi Arabic Qwen3-TTS, Emirati Qwen3.5-TTS, VoxCPM2, Voxtral TTS, and eSpeak NG when pronunciation or runtime needs change; track Thaka KSAA-2026 speech diacritization as research only until code/weights are released; keep Kyutai Pocket TTS, NAMAA-Saudi-TTS-V2, Arabic-F5-TTS-v2, Arabic XTTS-v2 Egyptian fine-tune, and Voxtral TTS personal/license-review only or excluded until Arabic support/licensing is verified, and keep base Qwen3-TTS out until Arabic support is verified.
|
| 143 |
+
7. Store generated audio on the worker as short-lived downloadable files by default; use Vercel Blob or object storage only when permanent hosted links are worth the free-tier quota tradeoff.
|
| 144 |
+
8. Verify a deployed worker with both embedded-text and scanned-OCR smoke tests, usable extracted text, OCR-path proof, and real audio file signatures before treating the hosted system as complete.
|
| 145 |
+
|
| 146 |
+
Run `python scripts\prove_local_readiness.py --refresh-research` to save a local readiness report before deployment. Run `python scripts\prove_live_deployment.py` after the hosted worker is live.
|
requirements-arabic-glm-ocr.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate==1.12.0
|
| 2 |
+
Pillow==11.3.0
|
| 3 |
+
torch==2.9.1
|
| 4 |
+
transformers==4.57.3
|
requirements-arabic-qwen-ocr.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate==1.12.0
|
| 2 |
+
Pillow==11.3.0
|
| 3 |
+
torch==2.9.1
|
| 4 |
+
transformers==4.57.3
|
| 5 |
+
qwen-vl-utils==0.0.14
|
requirements-baseer-ocr.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate>=0.26.0
|
| 2 |
+
qwen-vl-utils>=0.0.8
|
| 3 |
+
torch>=2.2.0
|
| 4 |
+
torchvision>=0.17.0
|
| 5 |
+
transformers>=4.57.0
|
| 6 |
+
Pillow>=10.0.0
|
requirements-katib-ocr.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=4.57.0
|
| 2 |
+
torch>=2.2.0
|
| 3 |
+
torchvision>=0.17.0
|
| 4 |
+
Pillow>=10.0.0
|
| 5 |
+
accelerate>=0.26.0
|
requirements-paddleocr-vl.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paddlepaddle>=3.2.1
|
| 2 |
+
paddleocr[doc-parser]>=3.6.0
|
requirements-paddleocr.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paddleocr==3.3.0
|
| 2 |
+
paddlepaddle==3.2.2
|
requirements-qari-ocr.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate>=0.26.0
|
| 2 |
+
peft>=0.10.0
|
| 3 |
+
qwen-vl-utils>=0.0.8
|
| 4 |
+
transformers>=4.57.0
|
| 5 |
+
torch>=2.2.0
|
| 6 |
+
torchvision>=0.17.0
|
| 7 |
+
Pillow>=10.0.0
|
requirements-silma.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cached_path==1.8.10
|
| 2 |
+
click==8.4.1
|
| 3 |
+
easyocr==1.7.2
|
| 4 |
+
ema-pytorch==0.7.9
|
| 5 |
+
hydra-core==1.3.2
|
| 6 |
+
librosa==0.11.0
|
| 7 |
+
matplotlib==3.10.9
|
| 8 |
+
ninja==1.13.0
|
| 9 |
+
numpy==1.26.4
|
| 10 |
+
opencv-python-headless==4.9.0.80
|
| 11 |
+
pillow==12.2.0
|
| 12 |
+
pydub==0.25.1
|
| 13 |
+
python-bidi==0.6.10
|
| 14 |
+
PyYAML==6.0.3
|
| 15 |
+
regex==2026.5.9
|
| 16 |
+
requests==2.34.2
|
| 17 |
+
safetensors==0.7.0
|
| 18 |
+
scikit-image==0.25.2
|
| 19 |
+
scikit-learn==1.7.2
|
| 20 |
+
scipy==1.15.3
|
| 21 |
+
soundfile==0.13.1
|
| 22 |
+
tokenizers==0.22.2
|
| 23 |
+
torch==2.12.0
|
| 24 |
+
torchaudio==2.11.0
|
| 25 |
+
torchcodec==0.14.0
|
| 26 |
+
torchdiffeq==0.2.5
|
| 27 |
+
torchvision==0.27.0
|
| 28 |
+
tqdm==4.67.3
|
| 29 |
+
transformers==5.10.2
|
| 30 |
+
transformers-stream-generator==0.0.5
|
| 31 |
+
Unidecode==1.4.0
|
| 32 |
+
vocos==0.1.0
|
| 33 |
+
x-transformers==2.19.12
|
requirements-supertonic.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
supertonic
|
requirements-tawkeed-ocr.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate>=0.26.0
|
| 2 |
+
qwen-vl-utils>=0.0.8
|
| 3 |
+
transformers>=4.57.0
|
| 4 |
+
torch>=2.2.0
|
| 5 |
+
torchvision>=0.17.0
|
| 6 |
+
Pillow>=10.0.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
python-multipart==0.0.19
|
| 4 |
+
PyMuPDF==1.24.14
|
| 5 |
+
pyttsx3==2.98
|
| 6 |
+
pytest==8.3.4
|
| 7 |
+
httpx==0.28.1
|
scripts/arabic_glm_ocr_extract.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import re
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
TAG_RE = re.compile(r"<[^>]+>")
|
| 9 |
+
DEFAULT_ARABIC_GLM_OCR_MODEL = "sherif1313/Arabic-GLM-OCR-v2"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def clean_model_text(text: str) -> str:
|
| 13 |
+
text = TAG_RE.sub("\n", text)
|
| 14 |
+
text = re.sub(r"```(?:html|markdown|text|json)?", "", text, flags=re.IGNORECASE)
|
| 15 |
+
text = text.replace("```", "")
|
| 16 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 17 |
+
return "\n".join(lines)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def main() -> None:
|
| 21 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 22 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 23 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 24 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 25 |
+
|
| 26 |
+
parser = argparse.ArgumentParser(description="Extract Arabic text from page images with Arabic-GLM-OCR.")
|
| 27 |
+
parser.add_argument("--image-dir", required=True, type=Path)
|
| 28 |
+
parser.add_argument("--out", required=True, type=Path)
|
| 29 |
+
parser.add_argument("--model", default=DEFAULT_ARABIC_GLM_OCR_MODEL)
|
| 30 |
+
parser.add_argument("--max-new-tokens", type=int, default=2048)
|
| 31 |
+
args = parser.parse_args()
|
| 32 |
+
|
| 33 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 34 |
+
total = max(len(image_paths), 1)
|
| 35 |
+
print(f"ARABIC_READER_PROGRESS 0 {total}", flush=True)
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
from PIL import Image
|
| 39 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 40 |
+
|
| 41 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 42 |
+
dtype = torch.float16 if device == "cuda" else torch.float32
|
| 43 |
+
processor = AutoProcessor.from_pretrained(args.model)
|
| 44 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 45 |
+
args.model,
|
| 46 |
+
torch_dtype=dtype,
|
| 47 |
+
device_map="auto" if device == "cuda" else None,
|
| 48 |
+
)
|
| 49 |
+
if device == "cpu":
|
| 50 |
+
model.to(device)
|
| 51 |
+
|
| 52 |
+
prompt = (
|
| 53 |
+
"Extract the Arabic text exactly as it appears on this scanned page. "
|
| 54 |
+
"Preserve reading order. Do not summarize, translate, explain, or correct the text."
|
| 55 |
+
)
|
| 56 |
+
pieces: list[str] = []
|
| 57 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 58 |
+
total = max(len(image_paths), 1)
|
| 59 |
+
for index, image_path in enumerate(image_paths, start=1):
|
| 60 |
+
image = Image.open(image_path).convert("RGB")
|
| 61 |
+
messages = [
|
| 62 |
+
{
|
| 63 |
+
"role": "user",
|
| 64 |
+
"content": [
|
| 65 |
+
{"type": "image", "image": image},
|
| 66 |
+
{"type": "text", "text": prompt},
|
| 67 |
+
],
|
| 68 |
+
}
|
| 69 |
+
]
|
| 70 |
+
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 71 |
+
inputs = processor(text=[text], images=[image], return_tensors="pt").to(model.device)
|
| 72 |
+
with torch.no_grad():
|
| 73 |
+
output = model.generate(**inputs, max_new_tokens=args.max_new_tokens, do_sample=False)
|
| 74 |
+
result = processor.decode(output[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True)
|
| 75 |
+
page_text = clean_model_text(result)
|
| 76 |
+
if page_text:
|
| 77 |
+
pieces.append(page_text)
|
| 78 |
+
print(f"ARABIC_READER_PROGRESS {index} {total}", flush=True)
|
| 79 |
+
|
| 80 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
args.out.write_text("\n\n".join(pieces), encoding="utf-8")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
scripts/arabic_qwen_ocr_extract.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import re
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
TAG_RE = re.compile(r"<[^>]+>")
|
| 10 |
+
DEFAULT_ARABIC_QWEN_OCR_MODEL = "sherif1313/Arabic-Qwen3.5-OCR-v4"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def clean_model_text(text: str) -> str:
|
| 14 |
+
text = TAG_RE.sub("\n", text)
|
| 15 |
+
text = re.sub(r"```(?:html|markdown|text)?", "", text, flags=re.IGNORECASE)
|
| 16 |
+
text = text.replace("```", "")
|
| 17 |
+
text = re.sub(r"(.)\1{4,}", r"\1\1\1", text)
|
| 18 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 19 |
+
cleaned: list[str] = []
|
| 20 |
+
seen: dict[str, int] = {}
|
| 21 |
+
for line in lines:
|
| 22 |
+
seen[line] = seen.get(line, 0) + 1
|
| 23 |
+
if seen[line] <= 2:
|
| 24 |
+
cleaned.append(line)
|
| 25 |
+
return "\n".join(cleaned)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def resize_for_qwen(image: Any) -> Any:
|
| 29 |
+
width, height = image.size
|
| 30 |
+
resized_width = ((width + 63) // 64) * 64
|
| 31 |
+
resized_height = ((height + 63) // 64) * 64
|
| 32 |
+
if (resized_width, resized_height) == (width, height):
|
| 33 |
+
return image
|
| 34 |
+
|
| 35 |
+
from PIL import Image
|
| 36 |
+
|
| 37 |
+
return image.resize((resized_width, resized_height), Image.Resampling.LANCZOS)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main() -> None:
|
| 41 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 42 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 43 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 44 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 45 |
+
|
| 46 |
+
parser = argparse.ArgumentParser(description="Extract Arabic text from page images with Arabic-Qwen3.5-OCR.")
|
| 47 |
+
parser.add_argument("--image-dir", required=True, type=Path)
|
| 48 |
+
parser.add_argument("--out", required=True, type=Path)
|
| 49 |
+
parser.add_argument("--model", default=DEFAULT_ARABIC_QWEN_OCR_MODEL)
|
| 50 |
+
parser.add_argument("--max-new-tokens", type=int, default=2048)
|
| 51 |
+
args = parser.parse_args()
|
| 52 |
+
|
| 53 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 54 |
+
total = max(len(image_paths), 1)
|
| 55 |
+
print(f"ARABIC_READER_PROGRESS 0 {total}", flush=True)
|
| 56 |
+
|
| 57 |
+
import torch
|
| 58 |
+
from PIL import Image
|
| 59 |
+
from qwen_vl_utils import process_vision_info
|
| 60 |
+
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
|
| 61 |
+
|
| 62 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 63 |
+
dtype = torch.float16 if device == "cuda" else torch.float32
|
| 64 |
+
processor = AutoProcessor.from_pretrained(args.model, trust_remote_code=True)
|
| 65 |
+
model = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 66 |
+
args.model,
|
| 67 |
+
torch_dtype=dtype,
|
| 68 |
+
device_map="auto" if device == "cuda" else None,
|
| 69 |
+
trust_remote_code=True,
|
| 70 |
+
low_cpu_mem_usage=True,
|
| 71 |
+
)
|
| 72 |
+
if device == "cpu":
|
| 73 |
+
model.to(device)
|
| 74 |
+
model.eval()
|
| 75 |
+
|
| 76 |
+
prompt = "اقرأ النص العربي في هذه الصورة كاملاً من البداية إلى النهاية بدون تلخيص أو ترجمة أو تصحيح."
|
| 77 |
+
pieces: list[str] = []
|
| 78 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 79 |
+
total = max(len(image_paths), 1)
|
| 80 |
+
for index, image_path in enumerate(image_paths, start=1):
|
| 81 |
+
image = resize_for_qwen(Image.open(image_path).convert("RGB"))
|
| 82 |
+
messages = [
|
| 83 |
+
{
|
| 84 |
+
"role": "user",
|
| 85 |
+
"content": [
|
| 86 |
+
{"type": "image", "image": image},
|
| 87 |
+
{"type": "text", "text": prompt},
|
| 88 |
+
],
|
| 89 |
+
}
|
| 90 |
+
]
|
| 91 |
+
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 92 |
+
image_inputs, _ = process_vision_info(messages)
|
| 93 |
+
inputs = processor(text=[text], images=image_inputs, padding=True, return_tensors="pt").to(model.device)
|
| 94 |
+
with torch.inference_mode():
|
| 95 |
+
output = model.generate(
|
| 96 |
+
**inputs,
|
| 97 |
+
max_new_tokens=args.max_new_tokens,
|
| 98 |
+
do_sample=False,
|
| 99 |
+
repetition_penalty=1.2,
|
| 100 |
+
no_repeat_ngram_size=3,
|
| 101 |
+
pad_token_id=processor.tokenizer.pad_token_id,
|
| 102 |
+
eos_token_id=processor.tokenizer.eos_token_id,
|
| 103 |
+
)
|
| 104 |
+
input_len = inputs.input_ids.shape[1]
|
| 105 |
+
result = processor.batch_decode(
|
| 106 |
+
output[:, input_len:],
|
| 107 |
+
skip_special_tokens=True,
|
| 108 |
+
clean_up_tokenization_spaces=False,
|
| 109 |
+
)[0]
|
| 110 |
+
page_text = clean_model_text(result)
|
| 111 |
+
if page_text:
|
| 112 |
+
pieces.append(page_text)
|
| 113 |
+
print(f"ARABIC_READER_PROGRESS {index} {total}", flush=True)
|
| 114 |
+
|
| 115 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
args.out.write_text("\n\n".join(pieces), encoding="utf-8")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
scripts/audit_goal_readiness.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
Status = Literal["PASS", "WARN", "FAIL"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class RequirementCheck:
|
| 16 |
+
requirement: str
|
| 17 |
+
status: Status
|
| 18 |
+
evidence: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
REQUIRED_SCANNED_WORKER_CHECKS = {
|
| 22 |
+
"scanned smoke upload accepted",
|
| 23 |
+
"scanned smoke job complete",
|
| 24 |
+
"scanned smoke usable text",
|
| 25 |
+
"scanned smoke OCR extraction",
|
| 26 |
+
"scanned smoke audio url",
|
| 27 |
+
"scanned smoke download url",
|
| 28 |
+
"scanned smoke audio bytes",
|
| 29 |
+
"scanned smoke audio file signature",
|
| 30 |
+
"scanned smoke download bytes",
|
| 31 |
+
"scanned smoke download file signature",
|
| 32 |
+
}
|
| 33 |
+
REQUIRED_EMBEDDED_WORKER_CHECKS = {
|
| 34 |
+
"smoke upload accepted",
|
| 35 |
+
"smoke job complete",
|
| 36 |
+
"smoke usable text",
|
| 37 |
+
"smoke audio url",
|
| 38 |
+
"smoke download url",
|
| 39 |
+
"smoke audio bytes",
|
| 40 |
+
"smoke audio file signature",
|
| 41 |
+
"smoke download bytes",
|
| 42 |
+
"smoke download file signature",
|
| 43 |
+
}
|
| 44 |
+
REQUIRED_WORKER_HEALTH_CHECKS = {
|
| 45 |
+
"recommended stack documented",
|
| 46 |
+
}
|
| 47 |
+
REQUIRED_SITE_CHECKS = {
|
| 48 |
+
"site session endpoint",
|
| 49 |
+
"site login",
|
| 50 |
+
"site session authenticated",
|
| 51 |
+
"site health after login",
|
| 52 |
+
"site platform vercel",
|
| 53 |
+
"site worker configured",
|
| 54 |
+
"site large PDF ready",
|
| 55 |
+
"site production worker ready",
|
| 56 |
+
"site hosted limits documented",
|
| 57 |
+
"site recommended stack documented",
|
| 58 |
+
"site direct cloud fallback disabled",
|
| 59 |
+
"site worker diagnostics endpoint",
|
| 60 |
+
"site worker reachable from vercel",
|
| 61 |
+
"site worker CORS ready",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def read_text(root: Path, relative: str) -> str:
|
| 66 |
+
path = root / relative
|
| 67 |
+
if not path.exists():
|
| 68 |
+
return ""
|
| 69 |
+
return path.read_text(encoding="utf-8", errors="replace")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def has_all(text: str, markers: list[str]) -> bool:
|
| 73 |
+
return all(marker in text for marker in markers)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def add(checks: list[RequirementCheck], requirement: str, status: Status, evidence: str) -> None:
|
| 77 |
+
checks.append(RequirementCheck(requirement, status, evidence))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def verify_worker_report(path: Path | None) -> tuple[bool, str]:
|
| 81 |
+
if path is None:
|
| 82 |
+
return False, "no worker verification report provided"
|
| 83 |
+
if not path.exists():
|
| 84 |
+
return False, f"worker verification report not found: {path}"
|
| 85 |
+
try:
|
| 86 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
return False, f"worker verification report is not valid JSON: {exc}"
|
| 89 |
+
if not isinstance(payload, list):
|
| 90 |
+
return False, "worker verification report must be a JSON list from scripts/verify_worker.py --json"
|
| 91 |
+
|
| 92 |
+
checks_by_name: dict[str, bool] = {}
|
| 93 |
+
failing: list[str] = []
|
| 94 |
+
for item in payload:
|
| 95 |
+
if not isinstance(item, dict):
|
| 96 |
+
continue
|
| 97 |
+
name = str(item.get("name", ""))
|
| 98 |
+
ok = bool(item.get("ok"))
|
| 99 |
+
checks_by_name[name] = ok
|
| 100 |
+
if not ok:
|
| 101 |
+
failing.append(name or "<unnamed>")
|
| 102 |
+
|
| 103 |
+
required_worker_checks = REQUIRED_WORKER_HEALTH_CHECKS | REQUIRED_EMBEDDED_WORKER_CHECKS | REQUIRED_SCANNED_WORKER_CHECKS
|
| 104 |
+
missing = sorted(name for name in required_worker_checks if name not in checks_by_name)
|
| 105 |
+
not_ok = sorted(name for name in required_worker_checks if checks_by_name.get(name) is False)
|
| 106 |
+
if missing:
|
| 107 |
+
return False, f"worker report is missing smoke checks: {', '.join(missing)}"
|
| 108 |
+
if not_ok:
|
| 109 |
+
return False, f"worker smoke checks failed: {', '.join(not_ok)}"
|
| 110 |
+
if failing:
|
| 111 |
+
return False, f"worker report contains failed checks: {', '.join(failing)}"
|
| 112 |
+
return True, f"worker verification report passed recommended-stack, embedded, and scanned OCR/audio/download checks: {path}"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def verify_site_report(path: Path | None) -> tuple[bool, str]:
|
| 116 |
+
if path is None:
|
| 117 |
+
return False, "no Vercel site verification report provided"
|
| 118 |
+
if not path.exists():
|
| 119 |
+
return False, f"Vercel site verification report not found: {path}"
|
| 120 |
+
try:
|
| 121 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
return False, f"Vercel site verification report is not valid JSON: {exc}"
|
| 124 |
+
if not isinstance(payload, list):
|
| 125 |
+
return False, "Vercel site verification report must be a JSON list from scripts/verify_site.py --json"
|
| 126 |
+
|
| 127 |
+
checks_by_name: dict[str, bool] = {}
|
| 128 |
+
failing: list[str] = []
|
| 129 |
+
for item in payload:
|
| 130 |
+
if not isinstance(item, dict):
|
| 131 |
+
continue
|
| 132 |
+
name = str(item.get("name", ""))
|
| 133 |
+
ok = bool(item.get("ok"))
|
| 134 |
+
checks_by_name[name] = ok
|
| 135 |
+
if not ok:
|
| 136 |
+
failing.append(name or "<unnamed>")
|
| 137 |
+
|
| 138 |
+
missing = sorted(name for name in REQUIRED_SITE_CHECKS if name not in checks_by_name)
|
| 139 |
+
not_ok = sorted(name for name in REQUIRED_SITE_CHECKS if checks_by_name.get(name) is False)
|
| 140 |
+
if missing:
|
| 141 |
+
return False, f"Vercel site report is missing checks: {', '.join(missing)}"
|
| 142 |
+
if not_ok:
|
| 143 |
+
return False, f"Vercel site checks failed: {', '.join(not_ok)}"
|
| 144 |
+
if failing:
|
| 145 |
+
return False, f"Vercel site report contains failed checks: {', '.join(failing)}"
|
| 146 |
+
return True, f"Vercel site verification report passed login/worker-routing checks: {path}"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def collect_checks(
|
| 150 |
+
root: Path = ROOT_DIR,
|
| 151 |
+
live_worker_verified: bool = False,
|
| 152 |
+
worker_report: Path | None = None,
|
| 153 |
+
site_report: Path | None = None,
|
| 154 |
+
) -> list[RequirementCheck]:
|
| 155 |
+
checks: list[RequirementCheck] = []
|
| 156 |
+
report_verified, report_evidence = verify_worker_report(worker_report)
|
| 157 |
+
site_verified, site_evidence = verify_site_report(site_report)
|
| 158 |
+
app = read_text(root, "app/main.py")
|
| 159 |
+
frontend = read_text(root, "static/app.js") + "\n" + read_text(root, "static/index.html")
|
| 160 |
+
readme = read_text(root, "README.md")
|
| 161 |
+
research = read_text(root, "docs/best-free-arabic-pdf-audio-stack.md")
|
| 162 |
+
source_evidence = read_text(root, "docs/source-evidence.md")
|
| 163 |
+
recommendation = read_text(root, "docs/recommended-free-stack.md")
|
| 164 |
+
hf_metadata = read_text(root, "docs/huggingface-model-metadata.md")
|
| 165 |
+
user_guide = read_text(root, "docs/father-user-guide.md")
|
| 166 |
+
production = read_text(root, "docs/production-worker-architecture.md")
|
| 167 |
+
deployment_checklist = read_text(root, "docs/live-deployment-checklist.md")
|
| 168 |
+
dockerfile = read_text(root, "Dockerfile.worker")
|
| 169 |
+
verify_worker = read_text(root, "scripts/verify_worker.py")
|
| 170 |
+
prove_local = read_text(root, "scripts/prove_local_readiness.py")
|
| 171 |
+
deployment_handoff = read_text(root, "scripts/deployment_handoff.py")
|
| 172 |
+
deployment_env_validator = read_text(root, "scripts/validate_deployment_env.py")
|
| 173 |
+
research_checker = read_text(root, "scripts/check_research_sources.py")
|
| 174 |
+
research_watchlist = read_text(root, "scripts/research_watchlist.py")
|
| 175 |
+
benchmark = read_text(root, "scripts/benchmark_ocr.py")
|
| 176 |
+
voices = read_text(root, "scripts/benchmark_voices.py")
|
| 177 |
+
voice_scorer = read_text(root, "scripts/score_voice_listening.py")
|
| 178 |
+
promotion_gate = read_text(root, "scripts/model_promotion_gate.py")
|
| 179 |
+
tts_sample_exporter = read_text(root, "scripts/export_tts_sample.py")
|
| 180 |
+
ocr_sample_exporter = read_text(root, "scripts/export_ocr_sample_images.py")
|
| 181 |
+
external_ocr_scorer = read_text(root, "scripts/score_external_ocr.py")
|
| 182 |
+
|
| 183 |
+
add(
|
| 184 |
+
checks,
|
| 185 |
+
"Code-locked access with persistent signed cookie",
|
| 186 |
+
"PASS" if has_all(app, ["ACCESS_CODE", "SECRET_KEY", "hmac.new", "set_cookie", "arabic_tts_auth"]) else "FAIL",
|
| 187 |
+
"app/main.py contains access-code login, HMAC signing, and cookie storage",
|
| 188 |
+
)
|
| 189 |
+
add(
|
| 190 |
+
checks,
|
| 191 |
+
"Large Arabic PDF upload path",
|
| 192 |
+
"PASS" if has_all(app, ['DEFAULT_MAX_UPLOAD_MB = "4" if IS_VERCEL else "512"', "MAX_UPLOAD_BYTES", "WORKER_BASE_URL"]) else "FAIL",
|
| 193 |
+
"local/worker default is 512 MB; Vercel shell can delegate to WORKER_BASE_URL",
|
| 194 |
+
)
|
| 195 |
+
add(
|
| 196 |
+
checks,
|
| 197 |
+
"Free Arabic OCR engine stack",
|
| 198 |
+
"PASS"
|
| 199 |
+
if has_all(
|
| 200 |
+
app,
|
| 201 |
+
[
|
| 202 |
+
"OCR_ENGINE_CHOICES",
|
| 203 |
+
"arabic",
|
| 204 |
+
"arabic-max",
|
| 205 |
+
"qari-ocr",
|
| 206 |
+
"tawkeed-ocr",
|
| 207 |
+
"katib-ocr",
|
| 208 |
+
"arabic-qwen-ocr",
|
| 209 |
+
"arabic-glm-ocr",
|
| 210 |
+
"paddleocr",
|
| 211 |
+
"easyocr",
|
| 212 |
+
"tesseract",
|
| 213 |
+
"paddleocr-vl",
|
| 214 |
+
],
|
| 215 |
+
)
|
| 216 |
+
and (root / "requirements-paddleocr.txt").exists()
|
| 217 |
+
and (root / "requirements-paddleocr-vl.txt").exists()
|
| 218 |
+
and (root / "requirements-qari-ocr.txt").exists()
|
| 219 |
+
and (root / "requirements-tawkeed-ocr.txt").exists()
|
| 220 |
+
and (root / "requirements-katib-ocr.txt").exists()
|
| 221 |
+
and (root / "requirements-arabic-qwen-ocr.txt").exists()
|
| 222 |
+
and (root / "requirements-arabic-glm-ocr.txt").exists()
|
| 223 |
+
else "FAIL",
|
| 224 |
+
"Maximum Arabic OCR plus optional QARI-OCR, Tawkeed, KATIB, Arabic-Qwen, Arabic-GLM, Baseer, EasyOCR, PaddleOCR, Tesseract, and optional PaddleOCR-VL",
|
| 225 |
+
)
|
| 226 |
+
add(
|
| 227 |
+
checks,
|
| 228 |
+
"OCR quality gate before speech",
|
| 229 |
+
"PASS" if has_all(app, ["assess_text_quality", "prepare_text_for_speech", "OCR text quality is poor"]) else "FAIL",
|
| 230 |
+
"bad OCR is rejected before TTS; speech cleanup is centralized",
|
| 231 |
+
)
|
| 232 |
+
add(
|
| 233 |
+
checks,
|
| 234 |
+
"Free Arabic TTS options",
|
| 235 |
+
"PASS"
|
| 236 |
+
if has_all(app, ["silma-local", "habibi-msa", "supertonic-ar", "espeak-ar-clear"])
|
| 237 |
+
and has_all(voices, ["DEFAULT_VOICES", "RECOMMENDED_VOICE_ORDER", "choose_recommended_voice"])
|
| 238 |
+
else "FAIL",
|
| 239 |
+
"SILMA default, optional Habibi MSA, optional Supertonic CPU voice, eSpeak NG fallback, and quality-first voice recommendation are available",
|
| 240 |
+
)
|
| 241 |
+
add(
|
| 242 |
+
checks,
|
| 243 |
+
"Downloadable generated audio with local storage",
|
| 244 |
+
"PASS" if has_all(app, ["download_audio", "FileResponse", "sqlite3", "cleanup_output_storage", "AUDIO_FORMAT"]) else "FAIL",
|
| 245 |
+
"SQLite metadata, retained output files, audio streaming, and download endpoint",
|
| 246 |
+
)
|
| 247 |
+
add(
|
| 248 |
+
checks,
|
| 249 |
+
"Father-friendly workflow controls",
|
| 250 |
+
"PASS"
|
| 251 |
+
if has_all(frontend, ["voiceSelect", "pageLimitSelect", "ocrModeSelect", "downloadLink", "Use settings"])
|
| 252 |
+
and has_all(user_guide, ["Normal Use", "Quick test", "Download Audio"])
|
| 253 |
+
else "FAIL",
|
| 254 |
+
"voice, OCR, speed/page controls, download link, recent-job reuse controls, and a non-technical user guide exist",
|
| 255 |
+
)
|
| 256 |
+
add(
|
| 257 |
+
checks,
|
| 258 |
+
"Vercel plus worker deployment shape",
|
| 259 |
+
"PASS"
|
| 260 |
+
if has_all(
|
| 261 |
+
readme + production + deployment_checklist + dockerfile,
|
| 262 |
+
["WORKER_BASE_URL", "Docker", "OCR_ENGINE=arabic-max", "AUDIO_FORMAT=mp3", "worker-verification.json"],
|
| 263 |
+
)
|
| 264 |
+
and has_all(deployment_handoff, ["WORKER_BASE_URL", "prove_live_deployment.py", "worker-verification.json"])
|
| 265 |
+
and has_all(
|
| 266 |
+
deployment_env_validator,
|
| 267 |
+
["WORKER_BASE_URL", "ENABLE_DIRECT_CLOUD_TTS", "CORS_ORIGINS", "SECRET_KEY matches"],
|
| 268 |
+
)
|
| 269 |
+
else "FAIL",
|
| 270 |
+
"Vercel shell plus Docker worker is documented, packaged, tied to a verification report, has env validation, and has a handoff generator",
|
| 271 |
+
)
|
| 272 |
+
add(
|
| 273 |
+
checks,
|
| 274 |
+
"Live worker smoke verification command",
|
| 275 |
+
"PASS"
|
| 276 |
+
if has_all(
|
| 277 |
+
verify_worker,
|
| 278 |
+
[
|
| 279 |
+
"--smoke-upload",
|
| 280 |
+
"--smoke-scanned",
|
| 281 |
+
"create_scanned_smoke_pdf",
|
| 282 |
+
"looks_like_audio",
|
| 283 |
+
"job_has_usable_text",
|
| 284 |
+
"scanned smoke OCR extraction",
|
| 285 |
+
],
|
| 286 |
+
)
|
| 287 |
+
else "FAIL",
|
| 288 |
+
"verify_worker.py can prove embedded-text and scanned-OCR text/audio/download paths with audio file signatures",
|
| 289 |
+
)
|
| 290 |
+
add(
|
| 291 |
+
checks,
|
| 292 |
+
"Book-specific OCR/voice benchmarking process",
|
| 293 |
+
"PASS"
|
| 294 |
+
if has_all(
|
| 295 |
+
benchmark
|
| 296 |
+
+ voices
|
| 297 |
+
+ voice_scorer
|
| 298 |
+
+ prove_local
|
| 299 |
+
+ tts_sample_exporter
|
| 300 |
+
+ ocr_sample_exporter
|
| 301 |
+
+ external_ocr_scorer
|
| 302 |
+
+ promotion_gate
|
| 303 |
+
+ readme,
|
| 304 |
+
[
|
| 305 |
+
"recommendation_for_extraction",
|
| 306 |
+
"benchmark_ocr.py",
|
| 307 |
+
"benchmark_voices.py",
|
| 308 |
+
"score_voice_listening",
|
| 309 |
+
"Arabic Voice Listening Score",
|
| 310 |
+
"export_tts_sample",
|
| 311 |
+
"arabic-tts-sample.txt",
|
| 312 |
+
"export_ocr_sample_images",
|
| 313 |
+
"External Arabic OCR Sample",
|
| 314 |
+
"oi-OCR",
|
| 315 |
+
"score_external_ocr",
|
| 316 |
+
"External Arabic OCR Score Report",
|
| 317 |
+
"model_promotion_gate",
|
| 318 |
+
"Model Promotion Gate",
|
| 319 |
+
"same-sample proof",
|
| 320 |
+
"OmniVoice",
|
| 321 |
+
"OmniVoice Arabic LoRA",
|
| 322 |
+
"Lahgtna Chatterbox",
|
| 323 |
+
"NAMAA-Saudi-TTS",
|
| 324 |
+
"Chatterbox-Multilingual",
|
| 325 |
+
"Chatterbox-Multilingual ONNX",
|
| 326 |
+
"tts-arabic-onnx",
|
| 327 |
+
"Supertonic 3",
|
| 328 |
+
"Spark-TTS Arabic",
|
| 329 |
+
"Sofelia-TTS",
|
| 330 |
+
"Baseer",
|
| 331 |
+
"Arabic-GLM-OCR-v2",
|
| 332 |
+
"Arabic-Qwen3.5-OCR-v4",
|
| 333 |
+
"recommended",
|
| 334 |
+
"local-readiness.json",
|
| 335 |
+
],
|
| 336 |
+
)
|
| 337 |
+
else "FAIL",
|
| 338 |
+
"benchmark tools recommend OCR settings, export and score external OCR samples, compare voices, export external TTS samples, gate model promotion, and can write a local readiness report before full-book runs",
|
| 339 |
+
)
|
| 340 |
+
add(
|
| 341 |
+
checks,
|
| 342 |
+
"Current free/up-to-date research notes",
|
| 343 |
+
"PASS"
|
| 344 |
+
if has_all(
|
| 345 |
+
research,
|
| 346 |
+
[
|
| 347 |
+
"Last checked: June 7, 2026",
|
| 348 |
+
"QARI-OCR 0.4",
|
| 349 |
+
"Qari-OCR-0.4.0-VL-4B-Instruct",
|
| 350 |
+
"PaddleOCR-VL-1.6",
|
| 351 |
+
"oi-OCR",
|
| 352 |
+
"Arabic OCR Qwen2.5-VL GGUF",
|
| 353 |
+
"Arabic-Qwen3.5-OCR-v4",
|
| 354 |
+
"Baseer OCR",
|
| 355 |
+
"OmniVoice Arabic LoRA",
|
| 356 |
+
"Lahgtna Chatterbox",
|
| 357 |
+
"NAMAA-Saudi-TTS",
|
| 358 |
+
"Chatterbox-Multilingual",
|
| 359 |
+
"Chatterbox-Multilingual ONNX",
|
| 360 |
+
"tts-arabic-onnx",
|
| 361 |
+
"Supertonic 3",
|
| 362 |
+
"Spark-TTS Arabic",
|
| 363 |
+
"Sofelia-TTS",
|
| 364 |
+
"SILMA TTS",
|
| 365 |
+
"Supertone/supertonic-3",
|
| 366 |
+
"silma-ai/silma-tts",
|
| 367 |
+
"Apache-2.0 model weights",
|
| 368 |
+
"Habibi-TTS paper",
|
| 369 |
+
"Source Links",
|
| 370 |
+
],
|
| 371 |
+
)
|
| 372 |
+
and has_all(
|
| 373 |
+
source_evidence,
|
| 374 |
+
[
|
| 375 |
+
"Source Evidence",
|
| 376 |
+
"QARI-OCR 0.4 model",
|
| 377 |
+
"oi-OCR",
|
| 378 |
+
"Arabic OCR Qwen2.5-VL GGUF",
|
| 379 |
+
"SILMA Arabic TTS benchmark",
|
| 380 |
+
"NAMAA-Saudi-TTS",
|
| 381 |
+
"huggingface-model-metadata.md",
|
| 382 |
+
"Habibi-TTS paper",
|
| 383 |
+
"MMS Arabic TTS",
|
| 384 |
+
"Hosting Sources",
|
| 385 |
+
"Vercel Functions limits",
|
| 386 |
+
"4.5 MB request/response body limit",
|
| 387 |
+
"2 vCPU",
|
| 388 |
+
"16 GB RAM",
|
| 389 |
+
"50 GB non-persistent disk",
|
| 390 |
+
"Practical Conclusion",
|
| 391 |
+
],
|
| 392 |
+
)
|
| 393 |
+
and has_all(
|
| 394 |
+
recommendation,
|
| 395 |
+
[
|
| 396 |
+
"Recommended Free Arabic PDF To Audio Stack",
|
| 397 |
+
"PyMuPDF text extraction first",
|
| 398 |
+
"`OCR_ENGINE=arabic-max`",
|
| 399 |
+
"SILMA TTS",
|
| 400 |
+
"4.5 MB function payload limit",
|
| 401 |
+
"50 GB but non-persistent",
|
| 402 |
+
"Benchmark Before Promoting",
|
| 403 |
+
"PyMuPDF -> `arabic-max` OCR -> SILMA TTS",
|
| 404 |
+
],
|
| 405 |
+
)
|
| 406 |
+
and has_all(
|
| 407 |
+
hf_metadata,
|
| 408 |
+
[
|
| 409 |
+
"Last refreshed:",
|
| 410 |
+
"NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct",
|
| 411 |
+
"silma-ai/silma-tts",
|
| 412 |
+
"sherif1313/Arabic-Qwen3.5-OCR-v4",
|
| 413 |
+
"sherif1313/Arabic-GLM-OCR-v2",
|
| 414 |
+
"NAMAA-Space/NAMAA-Saudi-TTS",
|
| 415 |
+
"apache-2.0",
|
| 416 |
+
"cc-by-nc-4.0",
|
| 417 |
+
"fair-noncommercial-research-license",
|
| 418 |
+
"openrail",
|
| 419 |
+
],
|
| 420 |
+
)
|
| 421 |
+
and has_all(
|
| 422 |
+
research_checker,
|
| 423 |
+
["REQUIRED_SOURCE_MARKERS", "KEY_SOURCE_URLS", "check_url", "--check-key-links", "--check-links", "metadata_freshness_check"],
|
| 424 |
+
)
|
| 425 |
+
and has_all(research_watchlist, ["license_policy_violations", "is_production_default_safe", "BENCHMARK_ONLY_LICENSE_MARKERS"])
|
| 426 |
+
else "FAIL",
|
| 427 |
+
"research docs record current free OCR/TTS recommendation, external OCR benchmark candidates, source evidence, and source-check command",
|
| 428 |
+
)
|
| 429 |
+
add(
|
| 430 |
+
checks,
|
| 431 |
+
"Live deployed worker verified",
|
| 432 |
+
"PASS" if live_worker_verified or report_verified else "WARN",
|
| 433 |
+
report_evidence
|
| 434 |
+
if worker_report
|
| 435 |
+
else "provide --worker-report after running verify_worker.py --json --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max against the deployed worker",
|
| 436 |
+
)
|
| 437 |
+
add(
|
| 438 |
+
checks,
|
| 439 |
+
"Live Vercel shell verified",
|
| 440 |
+
"PASS" if site_verified else "WARN",
|
| 441 |
+
site_evidence
|
| 442 |
+
if site_report
|
| 443 |
+
else "provide --site-report after running verify_site.py --json against the deployed Vercel site",
|
| 444 |
+
)
|
| 445 |
+
return checks
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def summarize(checks: list[RequirementCheck]) -> dict[str, object]:
|
| 449 |
+
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}
|
| 450 |
+
for check in checks:
|
| 451 |
+
counts[check.status] += 1
|
| 452 |
+
return {
|
| 453 |
+
"ready": counts["FAIL"] == 0,
|
| 454 |
+
"complete": counts["FAIL"] == 0 and counts["WARN"] == 0,
|
| 455 |
+
"counts": counts,
|
| 456 |
+
"checks": [asdict(check) for check in checks],
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def print_table(checks: list[RequirementCheck]) -> None:
|
| 461 |
+
summary = summarize(checks)
|
| 462 |
+
print("Arabic PDF-to-clean-audio goal audit")
|
| 463 |
+
print(
|
| 464 |
+
f"Ready: {'yes' if summary['ready'] else 'no'} "
|
| 465 |
+
f"Complete: {'yes' if summary['complete'] else 'no'} "
|
| 466 |
+
f"PASS={summary['counts']['PASS']} WARN={summary['counts']['WARN']} FAIL={summary['counts']['FAIL']}"
|
| 467 |
+
)
|
| 468 |
+
print()
|
| 469 |
+
for check in checks:
|
| 470 |
+
print(f"[{check.status:<4}] {check.requirement}")
|
| 471 |
+
print(f" {check.evidence}")
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def main() -> None:
|
| 475 |
+
parser = argparse.ArgumentParser(description="Audit the repo against the Arabic PDF-to-clean-audio project goal.")
|
| 476 |
+
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
| 477 |
+
parser.add_argument(
|
| 478 |
+
"--live-worker-verified",
|
| 479 |
+
action="store_true",
|
| 480 |
+
help="Manual override after verify_worker.py --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max succeeds. Prefer --worker-report for stronger evidence.",
|
| 481 |
+
)
|
| 482 |
+
parser.add_argument(
|
| 483 |
+
"--worker-report",
|
| 484 |
+
type=Path,
|
| 485 |
+
help="JSON output from scripts/verify_worker.py --json --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max against the deployed worker.",
|
| 486 |
+
)
|
| 487 |
+
parser.add_argument(
|
| 488 |
+
"--site-report",
|
| 489 |
+
type=Path,
|
| 490 |
+
help="JSON output from scripts/verify_site.py --json against the deployed Vercel site.",
|
| 491 |
+
)
|
| 492 |
+
args = parser.parse_args()
|
| 493 |
+
|
| 494 |
+
checks = collect_checks(live_worker_verified=args.live_worker_verified, worker_report=args.worker_report, site_report=args.site_report)
|
| 495 |
+
if args.json:
|
| 496 |
+
print(json.dumps(summarize(checks), ensure_ascii=False, indent=2))
|
| 497 |
+
else:
|
| 498 |
+
print_table(checks)
|
| 499 |
+
if any(check.status == "FAIL" for check in checks):
|
| 500 |
+
raise SystemExit(1)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
if __name__ == "__main__":
|
| 504 |
+
main()
|
scripts/baseer_ocr_extract.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
TAG_RE = re.compile(r"<[^>]+>")
|
| 10 |
+
DEFAULT_BASEER_OCR_MODEL = "AbdoTarek/Baseer-OCR-V1.0"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def clean_model_text(text: str) -> str:
|
| 14 |
+
text = TAG_RE.sub("\n", text)
|
| 15 |
+
text = re.sub(r"```(?:json|html|markdown|text)?", "", text, flags=re.IGNORECASE)
|
| 16 |
+
text = text.replace("```", "").strip()
|
| 17 |
+
try:
|
| 18 |
+
payload = json.loads(text)
|
| 19 |
+
except json.JSONDecodeError:
|
| 20 |
+
payload = None
|
| 21 |
+
if isinstance(payload, dict):
|
| 22 |
+
full_text = payload.get("full_text") or payload.get("text") or payload.get("content")
|
| 23 |
+
if isinstance(full_text, str):
|
| 24 |
+
text = full_text
|
| 25 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 26 |
+
return "\n".join(lines)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> None:
|
| 30 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 31 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 32 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 33 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 34 |
+
|
| 35 |
+
parser = argparse.ArgumentParser(description="Extract Arabic text from page images with Baseer OCR.")
|
| 36 |
+
parser.add_argument("--image-dir", required=True, type=Path)
|
| 37 |
+
parser.add_argument("--out", required=True, type=Path)
|
| 38 |
+
parser.add_argument("--model", default=DEFAULT_BASEER_OCR_MODEL)
|
| 39 |
+
parser.add_argument("--max-new-tokens", type=int, default=2048)
|
| 40 |
+
args = parser.parse_args()
|
| 41 |
+
|
| 42 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 43 |
+
total = max(len(image_paths), 1)
|
| 44 |
+
print(f"ARABIC_READER_PROGRESS 0 {total}", flush=True)
|
| 45 |
+
|
| 46 |
+
import torch
|
| 47 |
+
from PIL import Image
|
| 48 |
+
from qwen_vl_utils import process_vision_info
|
| 49 |
+
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
|
| 50 |
+
|
| 51 |
+
model = Qwen2VLForConditionalGeneration.from_pretrained(
|
| 52 |
+
args.model,
|
| 53 |
+
torch_dtype="auto",
|
| 54 |
+
device_map="auto",
|
| 55 |
+
).eval()
|
| 56 |
+
processor = AutoProcessor.from_pretrained(args.model)
|
| 57 |
+
prompt = (
|
| 58 |
+
"Extract ALL visible Arabic text from the document image. "
|
| 59 |
+
"Return only JSON with a full_text field. Preserve the original reading order. "
|
| 60 |
+
"Do not summarize, translate, or add explanations."
|
| 61 |
+
)
|
| 62 |
+
pieces: list[str] = []
|
| 63 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 64 |
+
total = max(len(image_paths), 1)
|
| 65 |
+
for index, image_path in enumerate(image_paths, start=1):
|
| 66 |
+
image = Image.open(image_path).convert("RGB")
|
| 67 |
+
messages = [
|
| 68 |
+
{"role": "system", "content": [{"type": "text", "text": "You are an OCR assistant."}]},
|
| 69 |
+
{
|
| 70 |
+
"role": "user",
|
| 71 |
+
"content": [
|
| 72 |
+
{"type": "image", "image": image},
|
| 73 |
+
{"type": "text", "text": prompt},
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
]
|
| 77 |
+
text = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
| 78 |
+
image_inputs, video_inputs = process_vision_info(messages)
|
| 79 |
+
inputs = processor(
|
| 80 |
+
text=[text],
|
| 81 |
+
images=image_inputs,
|
| 82 |
+
videos=video_inputs,
|
| 83 |
+
padding=True,
|
| 84 |
+
return_tensors="pt",
|
| 85 |
+
).to(model.device)
|
| 86 |
+
with torch.inference_mode():
|
| 87 |
+
output_ids = model.generate(**inputs, max_new_tokens=args.max_new_tokens, do_sample=False)
|
| 88 |
+
result = processor.batch_decode(
|
| 89 |
+
output_ids[:, inputs.input_ids.shape[1] :],
|
| 90 |
+
skip_special_tokens=True,
|
| 91 |
+
)[0]
|
| 92 |
+
page_text = clean_model_text(result)
|
| 93 |
+
if page_text:
|
| 94 |
+
pieces.append(page_text)
|
| 95 |
+
print(f"ARABIC_READER_PROGRESS {index} {total}", flush=True)
|
| 96 |
+
|
| 97 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 98 |
+
args.out.write_text("\n\n".join(pieces), encoding="utf-8")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
scripts/benchmark_ocr.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import fitz
|
| 13 |
+
|
| 14 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 15 |
+
if str(ROOT_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 17 |
+
|
| 18 |
+
from app import main
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
EXTRACTION_RE = re.compile(r"^(?:(?P<mode>best|arabic|arabic-max):)?(?P<engine>[a-z-]+)(?:@(?P<zoom>[0-9.]+)x)?(?:-psm(?P<psm>\d+))?$")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def make_limited_pdf(pdf_path: Path, page_limit: int | None) -> Path:
|
| 25 |
+
if not page_limit:
|
| 26 |
+
return pdf_path
|
| 27 |
+
limited = Path(tempfile.gettempdir()) / f"{pdf_path.stem}-first-{page_limit}-pages.pdf"
|
| 28 |
+
with fitz.open(pdf_path) as document:
|
| 29 |
+
output = fitz.open()
|
| 30 |
+
output.insert_pdf(document, from_page=0, to_page=min(page_limit, document.page_count) - 1)
|
| 31 |
+
output.save(limited)
|
| 32 |
+
return limited
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def text_metrics(text: str) -> dict[str, Any]:
|
| 36 |
+
speech_text = main.prepare_text_for_speech(text)
|
| 37 |
+
arabic_words = main.ARABIC_RE.findall(speech_text)
|
| 38 |
+
placeholder_count = speech_text.count("?") + speech_text.count("\ufffd")
|
| 39 |
+
common_hits = sum(1 for word in arabic_words if word in main.COMMON_ARABIC_WORDS)
|
| 40 |
+
quality = main.assess_text_quality(text, speech_text)
|
| 41 |
+
return {
|
| 42 |
+
"characters": len(text),
|
| 43 |
+
"speechCharacters": len(speech_text),
|
| 44 |
+
"arabicWords": len(arabic_words),
|
| 45 |
+
"placeholderCharacters": placeholder_count,
|
| 46 |
+
"placeholderRatio": round(placeholder_count / max(len(speech_text), 1), 4),
|
| 47 |
+
"commonArabicWords": common_hits,
|
| 48 |
+
"commonArabicWordRatio": round(common_hits / max(len(arabic_words), 1), 4),
|
| 49 |
+
"singleArabicWords": int(quality["metrics"]["singleArabicWords"]),
|
| 50 |
+
"singleArabicWordRatio": quality["metrics"]["singleArabicWordRatio"],
|
| 51 |
+
"fragmentLines": int(quality["metrics"]["fragmentLines"]),
|
| 52 |
+
"fragmentLineRatio": quality["metrics"]["fragmentLineRatio"],
|
| 53 |
+
"quality": quality["quality"],
|
| 54 |
+
"qualityScore": quality["score"],
|
| 55 |
+
"qualityReasons": quality["reasons"],
|
| 56 |
+
"preview": text[:180],
|
| 57 |
+
"speechPreview": speech_text[:180],
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def recommendation_for_extraction(extraction: str | None) -> dict[str, Any] | None:
|
| 62 |
+
if not extraction:
|
| 63 |
+
return None
|
| 64 |
+
match = EXTRACTION_RE.match(extraction)
|
| 65 |
+
if not match:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
engine = match.group("engine")
|
| 69 |
+
zoom = match.group("zoom")
|
| 70 |
+
psm = match.group("psm")
|
| 71 |
+
env: dict[str, str] = {}
|
| 72 |
+
notes: list[str] = []
|
| 73 |
+
|
| 74 |
+
if engine == "embedded":
|
| 75 |
+
return {
|
| 76 |
+
"summary": "This PDF has readable embedded text; OCR settings are not needed.",
|
| 77 |
+
"env": {},
|
| 78 |
+
"notes": ["Use the normal upload flow."],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
mode = match.group("mode")
|
| 82 |
+
if mode in {"arabic", "arabic-max"}:
|
| 83 |
+
env["OCR_ENGINE"] = mode
|
| 84 |
+
if mode == "arabic-max":
|
| 85 |
+
notes.append("Use Maximum Arabic OCR for the full run only if the worker can handle the heavier OCR comparison.")
|
| 86 |
+
else:
|
| 87 |
+
notes.append("Use Arabic specialist OCR for the full run; it compares Arabic-trained OCR outputs.")
|
| 88 |
+
return {
|
| 89 |
+
"summary": f"For the full book, use OCR_ENGINE={mode}.",
|
| 90 |
+
"env": env,
|
| 91 |
+
"notes": notes,
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if engine not in {
|
| 95 |
+
"easyocr",
|
| 96 |
+
"qari-ocr",
|
| 97 |
+
"tawkeed-ocr",
|
| 98 |
+
"katib-ocr",
|
| 99 |
+
"arabic-qwen-ocr",
|
| 100 |
+
"arabic-glm-ocr",
|
| 101 |
+
"baseer-ocr",
|
| 102 |
+
"paddleocr",
|
| 103 |
+
"paddleocr-vl",
|
| 104 |
+
"surya",
|
| 105 |
+
"tesseract",
|
| 106 |
+
}:
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
env["OCR_ENGINE"] = engine
|
| 110 |
+
if zoom:
|
| 111 |
+
if engine == "easyocr":
|
| 112 |
+
env["EASYOCR_RENDER_ZOOM"] = zoom
|
| 113 |
+
elif engine == "qari-ocr":
|
| 114 |
+
env["QARI_OCR_RENDER_ZOOM"] = zoom
|
| 115 |
+
elif engine == "tawkeed-ocr":
|
| 116 |
+
env["TAWKEED_OCR_RENDER_ZOOM"] = zoom
|
| 117 |
+
elif engine == "katib-ocr":
|
| 118 |
+
env["KATIB_OCR_RENDER_ZOOM"] = zoom
|
| 119 |
+
elif engine == "arabic-qwen-ocr":
|
| 120 |
+
env["ARABIC_QWEN_OCR_RENDER_ZOOM"] = zoom
|
| 121 |
+
elif engine == "arabic-glm-ocr":
|
| 122 |
+
env["ARABIC_GLM_OCR_RENDER_ZOOM"] = zoom
|
| 123 |
+
elif engine == "baseer-ocr":
|
| 124 |
+
env["BASEER_OCR_RENDER_ZOOM"] = zoom
|
| 125 |
+
elif engine == "paddleocr":
|
| 126 |
+
env["PADDLEOCR_RENDER_ZOOM"] = zoom
|
| 127 |
+
elif engine == "paddleocr-vl":
|
| 128 |
+
env["PADDLEOCR_VL_RENDER_ZOOM"] = zoom
|
| 129 |
+
elif engine == "surya":
|
| 130 |
+
env["SURYA_RENDER_ZOOM"] = zoom
|
| 131 |
+
elif engine == "tesseract":
|
| 132 |
+
env["OCR_RENDER_ZOOM"] = zoom
|
| 133 |
+
if psm and engine == "tesseract":
|
| 134 |
+
env["TESSERACT_PSM"] = psm
|
| 135 |
+
if engine == "tesseract":
|
| 136 |
+
notes.append("Confirm Tesseract Arabic data is installed before the full run.")
|
| 137 |
+
elif engine == "easyocr":
|
| 138 |
+
notes.append("Use the EasyOCR/SILMA sidecar environment for the full run.")
|
| 139 |
+
elif engine == "qari-ocr":
|
| 140 |
+
notes.append("Use the QARI-OCR Arabic VLM sidecar on a GPU or strong worker; expect much higher RAM/runtime.")
|
| 141 |
+
elif engine == "tawkeed-ocr":
|
| 142 |
+
notes.append("Use the Tawkeed Arabic OCR sidecar when QARI 4B is too heavy; benchmark it on a short sample first.")
|
| 143 |
+
elif engine == "katib-ocr":
|
| 144 |
+
notes.append("Use the KATIB Arabic OCR sidecar for a smaller Arabic-trained VLM; benchmark it on a short sample first.")
|
| 145 |
+
elif engine == "arabic-qwen-ocr":
|
| 146 |
+
notes.append("Use the Arabic-Qwen3.5 OCR sidecar for a 0.9B Arabic-trained VLM; benchmark it on a short sample first.")
|
| 147 |
+
elif engine == "arabic-glm-ocr":
|
| 148 |
+
notes.append("Use the Arabic-GLM OCR sidecar for a recent Arabic-trained OCR VLM; benchmark it on a short sample first.")
|
| 149 |
+
elif engine == "baseer-ocr":
|
| 150 |
+
notes.append("Use the Baseer Arabic OCR sidecar for complex Arabic document layouts; benchmark it on a short sample first.")
|
| 151 |
+
elif engine == "paddleocr":
|
| 152 |
+
notes.append("Use the PaddleOCR sidecar environment for the full run.")
|
| 153 |
+
elif engine == "paddleocr-vl":
|
| 154 |
+
notes.append("Use the PaddleOCR-VL sidecar on a strong worker; expect much higher RAM/runtime than PaddleOCR.")
|
| 155 |
+
elif engine == "surya":
|
| 156 |
+
notes.append("Use the Surya heavy-worker sidecar; expect higher RAM/runtime than PaddleOCR.")
|
| 157 |
+
|
| 158 |
+
env_text = " ".join(f"{key}={value}" for key, value in env.items())
|
| 159 |
+
return {
|
| 160 |
+
"summary": f"For the full book, use {env_text}.",
|
| 161 |
+
"env": env,
|
| 162 |
+
"notes": notes,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def benchmark_engine(pdf_path: Path, engine: str) -> dict[str, Any]:
|
| 167 |
+
previous_engine = main.OCR_ENGINE
|
| 168 |
+
main.OCR_ENGINE = engine
|
| 169 |
+
job = main.Job(id="dry-run", filename=pdf_path.name, ocr_engine=engine)
|
| 170 |
+
started = time.perf_counter()
|
| 171 |
+
try:
|
| 172 |
+
text = main.extract_pdf_text(pdf_path, job)
|
| 173 |
+
elapsed = round(time.perf_counter() - started, 2)
|
| 174 |
+
result = {
|
| 175 |
+
"engine": engine,
|
| 176 |
+
"ok": True,
|
| 177 |
+
"seconds": elapsed,
|
| 178 |
+
"pages": job.pages,
|
| 179 |
+
"extraction": job.extraction,
|
| 180 |
+
**text_metrics(text),
|
| 181 |
+
}
|
| 182 |
+
result["recommendation"] = recommendation_for_extraction(job.extraction)
|
| 183 |
+
return result
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
elapsed = round(time.perf_counter() - started, 2)
|
| 186 |
+
return {
|
| 187 |
+
"engine": engine,
|
| 188 |
+
"ok": False,
|
| 189 |
+
"seconds": elapsed,
|
| 190 |
+
"pages": job.pages,
|
| 191 |
+
"error": str(exc),
|
| 192 |
+
}
|
| 193 |
+
finally:
|
| 194 |
+
main.OCR_ENGINE = previous_engine
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def print_table(results: list[dict[str, Any]]) -> None:
|
| 198 |
+
print("engine ok sec pages chars words quality score extraction")
|
| 199 |
+
print("------------- ---- ----- ----- ------ ------ ------- ------- ----------")
|
| 200 |
+
for item in results:
|
| 201 |
+
print(
|
| 202 |
+
f"{item['engine']:<13} "
|
| 203 |
+
f"{str(item['ok']):<4} "
|
| 204 |
+
f"{item['seconds']:>5} "
|
| 205 |
+
f"{item.get('pages', 0):>5} "
|
| 206 |
+
f"{item.get('characters', 0):>6} "
|
| 207 |
+
f"{item.get('arabicWords', 0):>6} "
|
| 208 |
+
f"{item.get('quality', '-'):>7} "
|
| 209 |
+
f"{item.get('qualityScore', 0):>7} "
|
| 210 |
+
f"{item.get('extraction', '-')}"
|
| 211 |
+
)
|
| 212 |
+
successful = [item for item in results if item.get("ok")]
|
| 213 |
+
if successful:
|
| 214 |
+
best = max(successful, key=lambda item: (item.get("qualityScore", 0), item.get("arabicWords", 0)))
|
| 215 |
+
recommendation = best.get("recommendation")
|
| 216 |
+
if recommendation:
|
| 217 |
+
print()
|
| 218 |
+
print(f"Best full-book setting from this sample: {recommendation['summary']}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def main_cli() -> None:
|
| 222 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 223 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 224 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 225 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 226 |
+
|
| 227 |
+
parser = argparse.ArgumentParser(description="Benchmark Arabic OCR engines on the same PDF.")
|
| 228 |
+
parser.add_argument("pdf", type=Path, help="Arabic PDF to benchmark")
|
| 229 |
+
parser.add_argument(
|
| 230 |
+
"--engines",
|
| 231 |
+
nargs="+",
|
| 232 |
+
default=["easyocr", "paddleocr", "tesseract"],
|
| 233 |
+
choices=[
|
| 234 |
+
"arabic",
|
| 235 |
+
"arabic-max",
|
| 236 |
+
"qari-ocr",
|
| 237 |
+
"tawkeed-ocr",
|
| 238 |
+
"katib-ocr",
|
| 239 |
+
"arabic-qwen-ocr",
|
| 240 |
+
"arabic-glm-ocr",
|
| 241 |
+
"baseer-ocr",
|
| 242 |
+
"easyocr",
|
| 243 |
+
"paddleocr",
|
| 244 |
+
"paddleocr-vl",
|
| 245 |
+
"surya",
|
| 246 |
+
"tesseract",
|
| 247 |
+
"auto",
|
| 248 |
+
"best",
|
| 249 |
+
],
|
| 250 |
+
)
|
| 251 |
+
parser.add_argument("--page-limit", type=int, default=1, help="Benchmark only the first N pages by default.")
|
| 252 |
+
parser.add_argument("--json", action="store_true", help="Print full JSON results instead of a compact table.")
|
| 253 |
+
args = parser.parse_args()
|
| 254 |
+
|
| 255 |
+
if not args.pdf.exists():
|
| 256 |
+
raise FileNotFoundError(f"PDF not found: {args.pdf}")
|
| 257 |
+
if args.page_limit is not None and args.page_limit < 1:
|
| 258 |
+
raise ValueError("--page-limit must be 1 or greater.")
|
| 259 |
+
|
| 260 |
+
benchmark_pdf = make_limited_pdf(args.pdf, args.page_limit)
|
| 261 |
+
try:
|
| 262 |
+
results = [benchmark_engine(benchmark_pdf, engine) for engine in args.engines]
|
| 263 |
+
finally:
|
| 264 |
+
if benchmark_pdf != args.pdf:
|
| 265 |
+
benchmark_pdf.unlink(missing_ok=True)
|
| 266 |
+
|
| 267 |
+
if args.json:
|
| 268 |
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
| 269 |
+
else:
|
| 270 |
+
print_table(results)
|
| 271 |
+
print()
|
| 272 |
+
print("Tip: use --json to inspect text previews and errors.")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if __name__ == "__main__":
|
| 276 |
+
main_cli()
|
scripts/benchmark_voices.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
if str(ROOT_DIR) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 13 |
+
|
| 14 |
+
from app import main
|
| 15 |
+
from scripts.verify_voice import DEFAULT_SAMPLE_TEXT, synthesize_voice
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
DEFAULT_VOICES = ["silma-local", "habibi-msa", "supertonic-ar", "espeak-ar-clear"]
|
| 19 |
+
RECOMMENDED_VOICE_ORDER = ["silma-local", "habibi-msa", "supertonic-ar", "espeak-ar-clear", "espeak-ar", "espeak-ar-male"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def available_voice_ids() -> list[str]:
|
| 23 |
+
return list(main.LOCAL_VOICES)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def benchmark_voice(
|
| 27 |
+
voice_id: str,
|
| 28 |
+
text: str,
|
| 29 |
+
output_dir: Path,
|
| 30 |
+
audio_format: str = "wav",
|
| 31 |
+
) -> dict[str, Any]:
|
| 32 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
destination = output_dir / f"{voice_id}.{audio_format}"
|
| 34 |
+
try:
|
| 35 |
+
result = synthesize_voice(text, voice_id, destination, audio_format)
|
| 36 |
+
return {"ok": True, **result}
|
| 37 |
+
except Exception as exc:
|
| 38 |
+
return {
|
| 39 |
+
"ok": False,
|
| 40 |
+
"voiceId": voice_id,
|
| 41 |
+
"label": main.get_local_voice(voice_id).get("label", voice_id),
|
| 42 |
+
"engine": main.get_local_voice(voice_id).get("engine", ""),
|
| 43 |
+
"error": str(exc),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def benchmark_voices(
|
| 48 |
+
voices: list[str] | None = None,
|
| 49 |
+
text: str = DEFAULT_SAMPLE_TEXT,
|
| 50 |
+
output_dir: Path | None = None,
|
| 51 |
+
audio_format: str = "wav",
|
| 52 |
+
) -> dict[str, Any]:
|
| 53 |
+
selected_voices = voices or DEFAULT_VOICES
|
| 54 |
+
output = output_dir or (ROOT_DIR / "outputs" / "voice-benchmark")
|
| 55 |
+
results = [benchmark_voice(voice_id, text, output, audio_format) for voice_id in selected_voices]
|
| 56 |
+
successful = [item for item in results if item.get("ok")]
|
| 57 |
+
fastest = min(successful, key=lambda item: item.get("elapsedSeconds", float("inf"))) if successful else None
|
| 58 |
+
recommended = choose_recommended_voice(successful)
|
| 59 |
+
return {
|
| 60 |
+
"voices": selected_voices,
|
| 61 |
+
"textCharacters": len(main.prepare_text_for_speech(text)),
|
| 62 |
+
"outputDir": str(output),
|
| 63 |
+
"audioFormat": audio_format,
|
| 64 |
+
"results": results,
|
| 65 |
+
"fastest": fastest,
|
| 66 |
+
"recommended": recommended,
|
| 67 |
+
"ready": bool(successful),
|
| 68 |
+
"nextSteps": build_next_steps(results),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def choose_recommended_voice(successful: list[dict[str, Any]]) -> dict[str, Any] | None:
|
| 73 |
+
if not successful:
|
| 74 |
+
return None
|
| 75 |
+
by_voice_id = {str(item.get("voiceId")): item for item in successful}
|
| 76 |
+
for voice_id in RECOMMENDED_VOICE_ORDER:
|
| 77 |
+
if voice_id in by_voice_id:
|
| 78 |
+
return by_voice_id[voice_id]
|
| 79 |
+
return min(successful, key=lambda item: item.get("elapsedSeconds", float("inf")))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def build_next_steps(results: list[dict[str, Any]]) -> list[str]:
|
| 83 |
+
steps: list[str] = []
|
| 84 |
+
successful = [item for item in results if item.get("ok")]
|
| 85 |
+
failed = [item for item in results if not item.get("ok")]
|
| 86 |
+
if successful:
|
| 87 |
+
recommended = choose_recommended_voice(successful)
|
| 88 |
+
if recommended:
|
| 89 |
+
steps.append(
|
| 90 |
+
f"Recommended starting voice: {recommended.get('voiceId')} ({recommended.get('label')}). "
|
| 91 |
+
"Listen before processing a full book."
|
| 92 |
+
)
|
| 93 |
+
steps.append("Listen to the generated files and choose the most natural Arabic voice before processing a full book.")
|
| 94 |
+
fastest = min(successful, key=lambda item: item.get("elapsedSeconds", float("inf")))
|
| 95 |
+
steps.append(f"Fastest successful voice in this run: {fastest.get('voiceId')} ({fastest.get('elapsedSeconds')}s).")
|
| 96 |
+
else:
|
| 97 |
+
steps.append("No tested voice produced audio. Run scripts/preflight_check.py and install SILMA, Habibi, Supertonic, or eSpeak NG.")
|
| 98 |
+
if any(item.get("voiceId") == "habibi-msa" and not item.get("ok") for item in failed):
|
| 99 |
+
steps.append("Habibi MSA is optional. Install it with scripts/setup_habibi.ps1 if you want to compare the newer MSA voice.")
|
| 100 |
+
if any(item.get("voiceId") == "silma-local" and not item.get("ok") for item in failed):
|
| 101 |
+
steps.append("SILMA is the preferred default voice. Install it with scripts/setup_silma.ps1 for the best current local baseline.")
|
| 102 |
+
if any(item.get("voiceId") == "supertonic-ar" and not item.get("ok") for item in failed):
|
| 103 |
+
steps.append("Supertonic 3 is optional. Install it with scripts/setup_supertonic.ps1 when you want a fast CPU Arabic-capable comparison voice.")
|
| 104 |
+
return steps
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def write_markdown_report(path: Path, result: dict[str, Any]) -> None:
|
| 108 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
rating_command = build_rating_command(result)
|
| 110 |
+
lines = [
|
| 111 |
+
"# Arabic Voice Benchmark",
|
| 112 |
+
"",
|
| 113 |
+
f"Output directory: `{result.get('outputDir', '')}`",
|
| 114 |
+
f"Text characters: {result.get('textCharacters', 0)}",
|
| 115 |
+
f"Audio format: {result.get('audioFormat', 'wav')}",
|
| 116 |
+
]
|
| 117 |
+
recommended = result.get("recommended")
|
| 118 |
+
fastest = result.get("fastest")
|
| 119 |
+
if recommended:
|
| 120 |
+
lines.append(f"Recommended starting voice: `{recommended.get('voiceId')}` ({recommended.get('label', '-')})")
|
| 121 |
+
if fastest:
|
| 122 |
+
lines.append(f"Fastest successful voice: `{fastest.get('voiceId')}` ({fastest.get('elapsedSeconds')}s)")
|
| 123 |
+
lines.extend(
|
| 124 |
+
[
|
| 125 |
+
"",
|
| 126 |
+
"## Results",
|
| 127 |
+
"",
|
| 128 |
+
"| Voice | Label | Engine | Status | Time | Audio | Notes |",
|
| 129 |
+
"| --- | --- | --- | --- | ---: | --- | --- |",
|
| 130 |
+
]
|
| 131 |
+
)
|
| 132 |
+
for item in result.get("results", []):
|
| 133 |
+
status = "ok" if item.get("ok") else "failed"
|
| 134 |
+
elapsed = item.get("elapsedSeconds", "-")
|
| 135 |
+
audio = item.get("path", "-")
|
| 136 |
+
notes = item.get("error", "")
|
| 137 |
+
lines.append(
|
| 138 |
+
f"| {item.get('voiceId', '-')} | {item.get('label', '-')} | {item.get('engine', '-')} | "
|
| 139 |
+
f"{status} | {elapsed} | {audio} | {notes} |"
|
| 140 |
+
)
|
| 141 |
+
lines.extend(["", "## Next Steps", ""])
|
| 142 |
+
for step in result.get("nextSteps", []):
|
| 143 |
+
lines.append(f"- {step}")
|
| 144 |
+
lines.extend(
|
| 145 |
+
[
|
| 146 |
+
"",
|
| 147 |
+
"## Listening Score",
|
| 148 |
+
"",
|
| 149 |
+
"After listening, score each successful voice from 1 to 5 for pronunciation, naturalness, pacing, long-listen comfort, and artifact-free audio.",
|
| 150 |
+
"",
|
| 151 |
+
"```powershell",
|
| 152 |
+
rating_command,
|
| 153 |
+
"```",
|
| 154 |
+
]
|
| 155 |
+
)
|
| 156 |
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def build_rating_command(result: dict[str, Any]) -> str:
|
| 160 |
+
ratings = []
|
| 161 |
+
for item in result.get("results", []):
|
| 162 |
+
if item.get("ok"):
|
| 163 |
+
ratings.append(f"--rating {item.get('voiceId')}=5,5,5,5,5")
|
| 164 |
+
if not ratings:
|
| 165 |
+
ratings.append("--rating silma-local=5,5,5,5,5")
|
| 166 |
+
return (
|
| 167 |
+
"python scripts\\score_voice_listening.py "
|
| 168 |
+
+ " ".join(ratings)
|
| 169 |
+
+ " --write-report outputs\\voice-listening-score.md"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def print_table(result: dict[str, Any]) -> None:
|
| 174 |
+
print("voice ok sec engine file")
|
| 175 |
+
print("---------------- ---- ----- ---------- ----")
|
| 176 |
+
for item in result["results"]:
|
| 177 |
+
print(
|
| 178 |
+
f"{item.get('voiceId', '-'):<16} "
|
| 179 |
+
f"{str(item.get('ok')):<4} "
|
| 180 |
+
f"{item.get('elapsedSeconds', '-'):>5} "
|
| 181 |
+
f"{item.get('engine', '-'):<10} "
|
| 182 |
+
f"{item.get('path', item.get('error', '-'))}"
|
| 183 |
+
)
|
| 184 |
+
print()
|
| 185 |
+
for step in result["nextSteps"]:
|
| 186 |
+
print(f"- {step}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def main_cli() -> None:
|
| 190 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 191 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 192 |
+
parser = argparse.ArgumentParser(description="Compare free local Arabic TTS voices on the same text.")
|
| 193 |
+
parser.add_argument("--voices", nargs="+", default=DEFAULT_VOICES, choices=available_voice_ids())
|
| 194 |
+
parser.add_argument("--text", default=DEFAULT_SAMPLE_TEXT, help="Arabic text to synthesize for every voice.")
|
| 195 |
+
parser.add_argument("--text-file", type=Path, help="Read benchmark text from a UTF-8 file.")
|
| 196 |
+
parser.add_argument("--out-dir", type=Path, default=ROOT_DIR / "outputs" / "voice-benchmark")
|
| 197 |
+
parser.add_argument("--format", choices=["wav", "mp3"], default="wav")
|
| 198 |
+
parser.add_argument("--write-report", type=Path, help="Write a Markdown benchmark report.")
|
| 199 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact table.")
|
| 200 |
+
args = parser.parse_args()
|
| 201 |
+
|
| 202 |
+
text = args.text_file.read_text(encoding="utf-8") if args.text_file else args.text
|
| 203 |
+
result = benchmark_voices(args.voices, text, args.out_dir, args.format)
|
| 204 |
+
if args.write_report:
|
| 205 |
+
write_markdown_report(args.write_report, result)
|
| 206 |
+
if args.json:
|
| 207 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 208 |
+
else:
|
| 209 |
+
print_table(result)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
if __name__ == "__main__":
|
| 213 |
+
main_cli()
|
scripts/check_deployment_readiness.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 13 |
+
Status = Literal["PASS", "WARN", "FAIL"]
|
| 14 |
+
EXPORT_MANIFEST_NAME = ".export-manifest.json"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Check:
|
| 19 |
+
category: str
|
| 20 |
+
name: str
|
| 21 |
+
status: Status
|
| 22 |
+
detail: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def add(checks: list[Check], category: str, name: str, status: Status, detail: str) -> None:
|
| 26 |
+
checks.append(Check(category, name, status, detail))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_json(path: Path) -> dict:
|
| 30 |
+
try:
|
| 31 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 32 |
+
except Exception:
|
| 33 |
+
return {}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def dockerfile_copy_sources(dockerfile: str) -> list[str]:
|
| 37 |
+
sources: list[str] = []
|
| 38 |
+
for line in dockerfile.splitlines():
|
| 39 |
+
line = line.strip()
|
| 40 |
+
if not line.startswith("COPY "):
|
| 41 |
+
continue
|
| 42 |
+
parts = line.split()
|
| 43 |
+
if len(parts) < 3:
|
| 44 |
+
continue
|
| 45 |
+
sources.extend(parts[1:-1])
|
| 46 |
+
return sources
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def referenced_scripts(dockerfile: str) -> set[str]:
|
| 50 |
+
return set(re.findall(r"scripts/[A-Za-z0-9_.-]+\.sh", dockerfile))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def file_sha256(path: Path) -> str:
|
| 54 |
+
digest = hashlib.sha256()
|
| 55 |
+
with path.open("rb") as handle:
|
| 56 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 57 |
+
digest.update(chunk)
|
| 58 |
+
return digest.hexdigest()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def should_manifest_copy(path: Path) -> bool:
|
| 62 |
+
if path.name in {"__pycache__", ".pytest_cache", ".ruff_cache"}:
|
| 63 |
+
return False
|
| 64 |
+
if path.suffix in {".pyc", ".pyo", ".pyd"}:
|
| 65 |
+
return False
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def expected_export_manifest(root: Path | None = None) -> dict[str, str]:
|
| 70 |
+
root = root or ROOT_DIR
|
| 71 |
+
files: dict[str, str] = {}
|
| 72 |
+
for relative in [
|
| 73 |
+
"requirements.txt",
|
| 74 |
+
"requirements-silma.txt",
|
| 75 |
+
"requirements-supertonic.txt",
|
| 76 |
+
"requirements-paddleocr.txt",
|
| 77 |
+
"requirements-paddleocr-vl.txt",
|
| 78 |
+
"requirements-qari-ocr.txt",
|
| 79 |
+
"requirements-tawkeed-ocr.txt",
|
| 80 |
+
"requirements-katib-ocr.txt",
|
| 81 |
+
"requirements-arabic-qwen-ocr.txt",
|
| 82 |
+
"requirements-arabic-glm-ocr.txt",
|
| 83 |
+
"requirements-baseer-ocr.txt",
|
| 84 |
+
]:
|
| 85 |
+
path = root / relative
|
| 86 |
+
if path.exists():
|
| 87 |
+
files[relative] = file_sha256(path)
|
| 88 |
+
dockerfile = root / "Dockerfile.worker"
|
| 89 |
+
if dockerfile.exists():
|
| 90 |
+
files["Dockerfile"] = file_sha256(dockerfile)
|
| 91 |
+
for relative in ["app", "api", "docs", "static", "scripts"]:
|
| 92 |
+
base = root / relative
|
| 93 |
+
if not base.exists():
|
| 94 |
+
continue
|
| 95 |
+
for path in sorted(base.rglob("*")):
|
| 96 |
+
if path.is_file() and should_manifest_copy(path):
|
| 97 |
+
files[path.relative_to(root).as_posix()] = file_sha256(path)
|
| 98 |
+
return files
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def check_required_files(root: Path = ROOT_DIR) -> list[Check]:
|
| 102 |
+
checks: list[Check] = []
|
| 103 |
+
required = [
|
| 104 |
+
"app/main.py",
|
| 105 |
+
"api/index.py",
|
| 106 |
+
"static/index.html",
|
| 107 |
+
"static/app.js",
|
| 108 |
+
"static/styles.css",
|
| 109 |
+
"requirements.txt",
|
| 110 |
+
"vercel.json",
|
| 111 |
+
"Dockerfile.worker",
|
| 112 |
+
"requirements-silma.txt",
|
| 113 |
+
"requirements-supertonic.txt",
|
| 114 |
+
"requirements-paddleocr.txt",
|
| 115 |
+
"requirements-paddleocr-vl.txt",
|
| 116 |
+
"requirements-qari-ocr.txt",
|
| 117 |
+
"requirements-tawkeed-ocr.txt",
|
| 118 |
+
"requirements-katib-ocr.txt",
|
| 119 |
+
"requirements-arabic-qwen-ocr.txt",
|
| 120 |
+
"requirements-arabic-glm-ocr.txt",
|
| 121 |
+
"requirements-baseer-ocr.txt",
|
| 122 |
+
"scripts/setup_silma.sh",
|
| 123 |
+
"scripts/setup_supertonic.sh",
|
| 124 |
+
"scripts/setup_paddleocr.sh",
|
| 125 |
+
"scripts/setup_paddleocr_vl.sh",
|
| 126 |
+
"scripts/setup_qari_ocr.sh",
|
| 127 |
+
"scripts/setup_tawkeed_ocr.sh",
|
| 128 |
+
"scripts/setup_katib_ocr.sh",
|
| 129 |
+
"scripts/setup_arabic_qwen_ocr.sh",
|
| 130 |
+
"scripts/setup_arabic_glm_ocr.sh",
|
| 131 |
+
"scripts/setup_baseer_ocr.sh",
|
| 132 |
+
"scripts/qari_ocr_extract.py",
|
| 133 |
+
"scripts/tawkeed_ocr_extract.py",
|
| 134 |
+
"scripts/katib_ocr_extract.py",
|
| 135 |
+
"scripts/arabic_qwen_ocr_extract.py",
|
| 136 |
+
"scripts/arabic_glm_ocr_extract.py",
|
| 137 |
+
"scripts/baseer_ocr_extract.py",
|
| 138 |
+
"scripts/audit_goal_readiness.py",
|
| 139 |
+
"scripts/prove_local_readiness.py",
|
| 140 |
+
"scripts/prove_live_deployment.py",
|
| 141 |
+
"scripts/configure_vercel_worker.py",
|
| 142 |
+
"scripts/finish_live_deployment.py",
|
| 143 |
+
"scripts/deployment_handoff.py",
|
| 144 |
+
"scripts/prepare_live_deployment.py",
|
| 145 |
+
"scripts/validate_deployment_env.py",
|
| 146 |
+
"scripts/hosted_preflight.py",
|
| 147 |
+
"scripts/verify_site.py",
|
| 148 |
+
"scripts/verify_worker.py",
|
| 149 |
+
"scripts/preflight_check.py",
|
| 150 |
+
"scripts/check_research_sources.py",
|
| 151 |
+
"scripts/research_watchlist.py",
|
| 152 |
+
"scripts/refresh_research_evidence.py",
|
| 153 |
+
"scripts/export_tts_sample.py",
|
| 154 |
+
"scripts/export_ocr_sample_images.py",
|
| 155 |
+
"scripts/score_voice_listening.py",
|
| 156 |
+
"scripts/score_tts_preprocessor.py",
|
| 157 |
+
"scripts/score_external_ocr.py",
|
| 158 |
+
"scripts/model_promotion_gate.py",
|
| 159 |
+
"scripts/next_deployment_step.py",
|
| 160 |
+
"scripts/deployment_status.py",
|
| 161 |
+
"scripts/check_test_environment.py",
|
| 162 |
+
"docs/live-deployment-checklist.md",
|
| 163 |
+
"docs/father-user-guide.md",
|
| 164 |
+
"docs/source-evidence.md",
|
| 165 |
+
"docs/huggingface-model-metadata.md",
|
| 166 |
+
"docs/research-watchlist.md",
|
| 167 |
+
"docs/recommended-free-stack.md",
|
| 168 |
+
"docs/recommended-decision-card.md",
|
| 169 |
+
"docs/recommended-decision-card.json",
|
| 170 |
+
]
|
| 171 |
+
for relative in required:
|
| 172 |
+
path = root / relative
|
| 173 |
+
add(checks, "Files", relative, "PASS" if path.exists() else "FAIL", "exists" if path.exists() else "missing")
|
| 174 |
+
return checks
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def check_vercel(root: Path = ROOT_DIR) -> list[Check]:
|
| 178 |
+
checks: list[Check] = []
|
| 179 |
+
config_path = root / "vercel.json"
|
| 180 |
+
config = load_json(config_path)
|
| 181 |
+
add(checks, "Vercel", "config readable", "PASS" if config else "FAIL", str(config_path))
|
| 182 |
+
if not config:
|
| 183 |
+
return checks
|
| 184 |
+
has_builds = "builds" in config
|
| 185 |
+
has_functions = "functions" in config
|
| 186 |
+
add(
|
| 187 |
+
checks,
|
| 188 |
+
"Vercel",
|
| 189 |
+
"functions/builds conflict",
|
| 190 |
+
"FAIL" if has_builds and has_functions else "PASS",
|
| 191 |
+
"do not use builds and functions together" if has_builds and has_functions else "ok",
|
| 192 |
+
)
|
| 193 |
+
rewrites = config.get("rewrites") or []
|
| 194 |
+
rewrite_ok = any(item.get("destination") == "/api/index.py" for item in rewrites if isinstance(item, dict))
|
| 195 |
+
add(checks, "Vercel", "FastAPI rewrite", "PASS" if rewrite_ok else "FAIL", json.dumps(rewrites))
|
| 196 |
+
functions = config.get("functions") or {}
|
| 197 |
+
api_function = functions.get("api/index.py", {})
|
| 198 |
+
max_duration = api_function.get("maxDuration", 0)
|
| 199 |
+
add(
|
| 200 |
+
checks,
|
| 201 |
+
"Vercel",
|
| 202 |
+
"function maxDuration",
|
| 203 |
+
"PASS" if isinstance(max_duration, int) and max_duration >= 60 else "WARN",
|
| 204 |
+
str(max_duration),
|
| 205 |
+
)
|
| 206 |
+
return checks
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def check_worker(root: Path = ROOT_DIR) -> list[Check]:
|
| 210 |
+
checks: list[Check] = []
|
| 211 |
+
dockerfile_path = root / "Dockerfile.worker"
|
| 212 |
+
if not dockerfile_path.exists():
|
| 213 |
+
add(checks, "Worker", "Dockerfile.worker", "FAIL", "missing")
|
| 214 |
+
return checks
|
| 215 |
+
dockerfile = dockerfile_path.read_text(encoding="utf-8")
|
| 216 |
+
add(checks, "Worker", "base image", "PASS" if "python:3.10" in dockerfile else "WARN", "Python 3.10 is expected")
|
| 217 |
+
for package in ["tesseract-ocr-ara", "espeak-ng", "ffmpeg"]:
|
| 218 |
+
add(checks, "Worker", f"apt package {package}", "PASS" if package in dockerfile else "FAIL", package)
|
| 219 |
+
for env_key in ["WORK_DIR", "DATABASE_PATH", "OCR_ENGINE=arabic-max", "AUDIO_FORMAT=mp3"]:
|
| 220 |
+
add(checks, "Worker", f"env {env_key}", "PASS" if env_key in dockerfile else "WARN", env_key)
|
| 221 |
+
for arg in [
|
| 222 |
+
"ARG INSTALL_QARI_OCR=0",
|
| 223 |
+
"ARG INSTALL_TAWKEED_OCR=0",
|
| 224 |
+
"ARG INSTALL_KATIB_OCR=0",
|
| 225 |
+
"ARG INSTALL_ARABIC_QWEN_OCR=0",
|
| 226 |
+
"ARG INSTALL_ARABIC_GLM_OCR=0",
|
| 227 |
+
"ARG INSTALL_BASEER_OCR=0",
|
| 228 |
+
"ARG INSTALL_PADDLEOCR_VL=0",
|
| 229 |
+
"ARG INSTALL_SUPERTONIC=0",
|
| 230 |
+
]:
|
| 231 |
+
add(
|
| 232 |
+
checks,
|
| 233 |
+
"Worker",
|
| 234 |
+
f"optional build {arg.split()[1]}",
|
| 235 |
+
"PASS" if arg in dockerfile else "WARN",
|
| 236 |
+
"lets stronger workers install heavy OCR sidecars",
|
| 237 |
+
)
|
| 238 |
+
optional_scripts = {
|
| 239 |
+
"scripts/setup_supertonic.sh": "SUPERTONIC",
|
| 240 |
+
"scripts/setup_qari_ocr.sh": "QARI_OCR",
|
| 241 |
+
"scripts/setup_tawkeed_ocr.sh": "TAWKEED_OCR",
|
| 242 |
+
"scripts/setup_katib_ocr.sh": "KATIB_OCR",
|
| 243 |
+
"scripts/setup_arabic_qwen_ocr.sh": "ARABIC_QWEN_OCR",
|
| 244 |
+
"scripts/setup_arabic_glm_ocr.sh": "ARABIC_GLM_OCR",
|
| 245 |
+
"scripts/setup_baseer_ocr.sh": "BASEER_OCR",
|
| 246 |
+
"scripts/setup_paddleocr_vl.sh": "PADDLEOCR_VL",
|
| 247 |
+
}
|
| 248 |
+
for optional_script, build_arg_name in optional_scripts.items():
|
| 249 |
+
conditional_marker = f"if [ \"$INSTALL_{build_arg_name}\" = \"1\" ]"
|
| 250 |
+
add(
|
| 251 |
+
checks,
|
| 252 |
+
"Worker",
|
| 253 |
+
f"conditional {optional_script}",
|
| 254 |
+
"PASS" if optional_script in dockerfile and conditional_marker in dockerfile else "WARN",
|
| 255 |
+
"optional heavy OCR install is controlled by build arg",
|
| 256 |
+
)
|
| 257 |
+
for source in dockerfile_copy_sources(dockerfile):
|
| 258 |
+
source_path = root / source
|
| 259 |
+
add(
|
| 260 |
+
checks,
|
| 261 |
+
"Worker",
|
| 262 |
+
f"COPY source {source}",
|
| 263 |
+
"PASS" if source_path.exists() else "FAIL",
|
| 264 |
+
"exists" if source_path.exists() else "missing",
|
| 265 |
+
)
|
| 266 |
+
for script in sorted(referenced_scripts(dockerfile)):
|
| 267 |
+
script_path = root / script
|
| 268 |
+
add(checks, "Worker", f"referenced {script}", "PASS" if script_path.exists() else "FAIL", "exists" if script_path.exists() else "missing")
|
| 269 |
+
return checks
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def check_ignore_files(root: Path = ROOT_DIR) -> list[Check]:
|
| 273 |
+
checks: list[Check] = []
|
| 274 |
+
gitignore = (root / ".gitignore").read_text(encoding="utf-8") if (root / ".gitignore").exists() else ""
|
| 275 |
+
dockerignore = (root / ".dockerignore").read_text(encoding="utf-8") if (root / ".dockerignore").exists() else ""
|
| 276 |
+
vercelignore = (root / ".vercelignore").read_text(encoding="utf-8") if (root / ".vercelignore").exists() else ""
|
| 277 |
+
for name, content in [(".gitignore", gitignore), (".dockerignore", dockerignore), (".vercelignore", vercelignore)]:
|
| 278 |
+
add(checks, "Ignore", name, "PASS" if content else "WARN", "present" if content else "missing")
|
| 279 |
+
add(checks, "Ignore", f"{name} excludes .env", "PASS" if ".env" in content else "WARN", "secrets should not deploy")
|
| 280 |
+
add(
|
| 281 |
+
checks,
|
| 282 |
+
"Ignore",
|
| 283 |
+
f"{name} excludes outputs",
|
| 284 |
+
"PASS" if any(line.strip().rstrip("/") == "outputs" for line in content.splitlines()) else "WARN",
|
| 285 |
+
"generated handoffs/audio should not deploy or commit",
|
| 286 |
+
)
|
| 287 |
+
for required in ["app", "api", "static", "requirements.txt"]:
|
| 288 |
+
excluded = any(line.strip() == required for line in vercelignore.splitlines())
|
| 289 |
+
add(checks, "Ignore", f"Vercel keeps {required}", "FAIL" if excluded else "PASS", "not excluded" if not excluded else "excluded")
|
| 290 |
+
return checks
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def check_deployment_handoff(root: Path = ROOT_DIR) -> list[Check]:
|
| 294 |
+
checks: list[Check] = []
|
| 295 |
+
path = root / "scripts" / "deployment_handoff.py"
|
| 296 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 297 |
+
markers = {
|
| 298 |
+
"deployment prep command": "prepare_live_deployment.py",
|
| 299 |
+
"Vercel CLI production deploy": "vercel --prod --yes",
|
| 300 |
+
"Vercel worker env command": "vercel env add WORKER_BASE_URL production",
|
| 301 |
+
"Hugging Face deploy helper": "deploy_hf_space.py",
|
| 302 |
+
"Hugging Face Space bundle path": "outputs/huggingface-space",
|
| 303 |
+
"deployment status command": "deployment_status.py --worker-url",
|
| 304 |
+
"Vercel worker diagnostic command": "hosted_preflight.py",
|
| 305 |
+
"Vercel worker CORS diagnostic": "site worker CORS ready",
|
| 306 |
+
"live proof command": "prove_live_deployment.py",
|
| 307 |
+
"worker verification report": "worker-verification.json",
|
| 308 |
+
"copy paste secret block": "Copy/paste secret values",
|
| 309 |
+
"copy paste build args": "Copy/paste balanced build args",
|
| 310 |
+
"copy paste Vercel env": "Copy/paste Vercel values",
|
| 311 |
+
"Vercel direct cloud fallback cleanup": "vercel env rm ENABLE_DIRECT_CLOUD_TTS production --yes",
|
| 312 |
+
"Vercel Hugging Face token cleanup": "vercel env rm HF_API_TOKEN production --yes",
|
| 313 |
+
"Vercel Hugging Face model cleanup": "vercel env rm HF_TTS_MODEL production --yes",
|
| 314 |
+
"Vercel default voice cleanup": "vercel env rm DEFAULT_VOICE_ID production --yes",
|
| 315 |
+
}
|
| 316 |
+
for name, marker in markers.items():
|
| 317 |
+
add(
|
| 318 |
+
checks,
|
| 319 |
+
"Handoff",
|
| 320 |
+
name,
|
| 321 |
+
"PASS" if marker in text else "FAIL",
|
| 322 |
+
marker if marker in text else f"missing marker: {marker}",
|
| 323 |
+
)
|
| 324 |
+
return checks
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def check_deployment_quickstart(root: Path = ROOT_DIR) -> list[Check]:
|
| 328 |
+
checks: list[Check] = []
|
| 329 |
+
path = root / "outputs" / "deployment-quickstart.md"
|
| 330 |
+
if not path.exists():
|
| 331 |
+
add(checks, "Quickstart", "deployment quickstart", "WARN", "run scripts/prepare_live_deployment.py")
|
| 332 |
+
return checks
|
| 333 |
+
text = path.read_text(encoding="utf-8", errors="replace")
|
| 334 |
+
markers = {
|
| 335 |
+
"Hugging Face new Space link": "https://huggingface.co/new-space",
|
| 336 |
+
"Hugging Face Docker docs link": "https://huggingface.co/docs/hub/main/en/spaces-sdks-docker",
|
| 337 |
+
"Vercel new project link": "https://vercel.com/new",
|
| 338 |
+
"Vercel FastAPI docs link": "https://vercel.com/docs/frameworks/backend/fastapi",
|
| 339 |
+
"handoff command": "deployment_handoff.py",
|
| 340 |
+
"hosted preflight command": "hosted_preflight.py",
|
| 341 |
+
"hosted preflight report": "hosted-preflight.json",
|
| 342 |
+
"worker CORS ready reminder": "site worker CORS ready",
|
| 343 |
+
"worker bundle path": "outputs\\huggingface-space",
|
| 344 |
+
"final proof warning": "live-deployment-proof.json",
|
| 345 |
+
"safety checklist": "Safety Checklist",
|
| 346 |
+
"shared secret reminder": "same generated `SECRET_KEY`",
|
| 347 |
+
"worker URL reminder": "`WORKER_BASE_URL` at the exact Hugging Face Space URL",
|
| 348 |
+
"direct cloud fallback disabled": "direct Hugging Face TTS disabled",
|
| 349 |
+
"temporary cloud variable cleanup": "ENABLE_DIRECT_CLOUD_TTS",
|
| 350 |
+
"current next command": "Current Next Command",
|
| 351 |
+
}
|
| 352 |
+
for name, marker in markers.items():
|
| 353 |
+
add(
|
| 354 |
+
checks,
|
| 355 |
+
"Quickstart",
|
| 356 |
+
name,
|
| 357 |
+
"PASS" if marker in text else "FAIL",
|
| 358 |
+
marker if marker in text else f"missing marker: {marker}",
|
| 359 |
+
)
|
| 360 |
+
return checks
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def check_env_validator(root: Path = ROOT_DIR) -> list[Check]:
|
| 364 |
+
checks: list[Check] = []
|
| 365 |
+
path = root / "scripts" / "validate_deployment_env.py"
|
| 366 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 367 |
+
markers = {
|
| 368 |
+
"validator rejects Vercel as worker URL": "WORKER_BASE_URL is not Vercel site",
|
| 369 |
+
"validator prefers Hugging Face Space worker": "WORKER_BASE_URL uses free worker host",
|
| 370 |
+
"validator rejects wildcard CORS": "CORS_ORIGINS has no wildcard",
|
| 371 |
+
"validator rejects non-Vercel CORS origins": "CORS_ORIGINS are Vercel origins",
|
| 372 |
+
}
|
| 373 |
+
for name, marker in markers.items():
|
| 374 |
+
add(
|
| 375 |
+
checks,
|
| 376 |
+
"Env validator",
|
| 377 |
+
name,
|
| 378 |
+
"PASS" if marker in text else "FAIL",
|
| 379 |
+
marker if marker in text else f"missing marker: {marker}",
|
| 380 |
+
)
|
| 381 |
+
return checks
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def check_app_contract(root: Path = ROOT_DIR) -> list[Check]:
|
| 385 |
+
checks: list[Check] = []
|
| 386 |
+
app_path = root / "app" / "main.py"
|
| 387 |
+
static_path = root / "static" / "app.js"
|
| 388 |
+
verify_site_path = root / "scripts" / "verify_site.py"
|
| 389 |
+
audit_path = root / "scripts" / "audit_goal_readiness.py"
|
| 390 |
+
app_text = app_path.read_text(encoding="utf-8", errors="replace") if app_path.exists() else ""
|
| 391 |
+
static_text = static_path.read_text(encoding="utf-8", errors="replace") if static_path.exists() else ""
|
| 392 |
+
verify_text = verify_site_path.read_text(encoding="utf-8", errors="replace") if verify_site_path.exists() else ""
|
| 393 |
+
audit_text = audit_path.read_text(encoding="utf-8", errors="replace") if audit_path.exists() else ""
|
| 394 |
+
markers = {
|
| 395 |
+
"health exposes productionReady": (app_text, '"productionReady"'),
|
| 396 |
+
"health exposes nextAction": (app_text, '"nextAction"'),
|
| 397 |
+
"health blocks direct cloud fallback readiness": (app_text, "not direct_cloud_fallback"),
|
| 398 |
+
"ui shows deployment next action": (static_text, "deploymentStatus.nextAction"),
|
| 399 |
+
"ui warns when productionReady false": (static_text, "deploymentStatus.productionReady === false"),
|
| 400 |
+
"site verifier checks productionReady": (verify_text, "site production worker ready"),
|
| 401 |
+
"site verifier checks worker diagnostics": (verify_text, "site worker reachable from vercel"),
|
| 402 |
+
"site verifier checks worker CORS": (verify_text, "site worker CORS ready"),
|
| 403 |
+
"goal audit requires productionReady proof": (audit_text, "site production worker ready"),
|
| 404 |
+
"goal audit requires worker diagnostics proof": (audit_text, "site worker reachable from vercel"),
|
| 405 |
+
"goal audit requires worker CORS proof": (audit_text, "site worker CORS ready"),
|
| 406 |
+
}
|
| 407 |
+
for name, (text, marker) in markers.items():
|
| 408 |
+
add(
|
| 409 |
+
checks,
|
| 410 |
+
"App contract",
|
| 411 |
+
name,
|
| 412 |
+
"PASS" if marker in text else "FAIL",
|
| 413 |
+
marker if marker in text else f"missing marker: {marker}",
|
| 414 |
+
)
|
| 415 |
+
return checks
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def check_hf_space_export(root: Path = ROOT_DIR) -> list[Check]:
|
| 419 |
+
checks: list[Check] = []
|
| 420 |
+
export_dir = root / "outputs" / "huggingface-space"
|
| 421 |
+
if not export_dir.exists():
|
| 422 |
+
add(checks, "HF Space", "export bundle", "WARN", "run scripts/export_hf_space.py --force")
|
| 423 |
+
return checks
|
| 424 |
+
required = [
|
| 425 |
+
"Dockerfile",
|
| 426 |
+
"README.md",
|
| 427 |
+
".dockerignore",
|
| 428 |
+
EXPORT_MANIFEST_NAME,
|
| 429 |
+
"requirements.txt",
|
| 430 |
+
"requirements-silma.txt",
|
| 431 |
+
"requirements-supertonic.txt",
|
| 432 |
+
"requirements-paddleocr.txt",
|
| 433 |
+
"requirements-paddleocr-vl.txt",
|
| 434 |
+
"requirements-qari-ocr.txt",
|
| 435 |
+
"requirements-tawkeed-ocr.txt",
|
| 436 |
+
"requirements-katib-ocr.txt",
|
| 437 |
+
"requirements-arabic-qwen-ocr.txt",
|
| 438 |
+
"requirements-arabic-glm-ocr.txt",
|
| 439 |
+
"requirements-baseer-ocr.txt",
|
| 440 |
+
".export-complete",
|
| 441 |
+
"app/main.py",
|
| 442 |
+
"api/index.py",
|
| 443 |
+
"static/index.html",
|
| 444 |
+
"scripts/setup_silma.sh",
|
| 445 |
+
"scripts/setup_supertonic.sh",
|
| 446 |
+
"scripts/setup_paddleocr.sh",
|
| 447 |
+
"scripts/setup_paddleocr_vl.sh",
|
| 448 |
+
"scripts/setup_qari_ocr.sh",
|
| 449 |
+
"scripts/setup_tawkeed_ocr.sh",
|
| 450 |
+
"scripts/setup_katib_ocr.sh",
|
| 451 |
+
"scripts/setup_arabic_qwen_ocr.sh",
|
| 452 |
+
"scripts/setup_arabic_glm_ocr.sh",
|
| 453 |
+
"scripts/setup_baseer_ocr.sh",
|
| 454 |
+
"scripts/qari_ocr_extract.py",
|
| 455 |
+
"scripts/tawkeed_ocr_extract.py",
|
| 456 |
+
"scripts/katib_ocr_extract.py",
|
| 457 |
+
"scripts/arabic_qwen_ocr_extract.py",
|
| 458 |
+
"scripts/arabic_glm_ocr_extract.py",
|
| 459 |
+
"scripts/baseer_ocr_extract.py",
|
| 460 |
+
"scripts/audit_goal_readiness.py",
|
| 461 |
+
"scripts/prove_local_readiness.py",
|
| 462 |
+
"scripts/prove_live_deployment.py",
|
| 463 |
+
"scripts/deployment_handoff.py",
|
| 464 |
+
"scripts/prepare_live_deployment.py",
|
| 465 |
+
"scripts/validate_deployment_env.py",
|
| 466 |
+
"scripts/verify_site.py",
|
| 467 |
+
"scripts/check_research_sources.py",
|
| 468 |
+
"scripts/research_watchlist.py",
|
| 469 |
+
"scripts/refresh_research_evidence.py",
|
| 470 |
+
"scripts/export_tts_sample.py",
|
| 471 |
+
"scripts/export_ocr_sample_images.py",
|
| 472 |
+
"scripts/score_voice_listening.py",
|
| 473 |
+
"scripts/score_tts_preprocessor.py",
|
| 474 |
+
"scripts/score_external_ocr.py",
|
| 475 |
+
"scripts/model_promotion_gate.py",
|
| 476 |
+
"scripts/next_deployment_step.py",
|
| 477 |
+
"scripts/deployment_status.py",
|
| 478 |
+
"docs/live-deployment-checklist.md",
|
| 479 |
+
"docs/father-user-guide.md",
|
| 480 |
+
"docs/source-evidence.md",
|
| 481 |
+
"docs/huggingface-model-metadata.md",
|
| 482 |
+
"docs/research-watchlist.md",
|
| 483 |
+
"docs/recommended-free-stack.md",
|
| 484 |
+
"docs/recommended-decision-card.md",
|
| 485 |
+
"docs/recommended-decision-card.json",
|
| 486 |
+
]
|
| 487 |
+
forbidden = [".env", "uploads", "outputs", "data", "test_pdfs", ".venv", ".venv-silma", ".venv-ocr"]
|
| 488 |
+
for relative in required:
|
| 489 |
+
path = export_dir / relative
|
| 490 |
+
add(checks, "HF Space", f"bundle has {relative}", "PASS" if path.exists() else "FAIL", "exists" if path.exists() else "missing")
|
| 491 |
+
manifest_path = export_dir / EXPORT_MANIFEST_NAME
|
| 492 |
+
manifest = load_json(manifest_path)
|
| 493 |
+
manifest_files = manifest.get("files") if isinstance(manifest, dict) else None
|
| 494 |
+
expected_manifest = expected_export_manifest(root)
|
| 495 |
+
if isinstance(manifest_files, dict):
|
| 496 |
+
missing_manifest = sorted(path for path in expected_manifest if path not in manifest_files)
|
| 497 |
+
changed_manifest = sorted(
|
| 498 |
+
path for path, digest in expected_manifest.items() if manifest_files.get(path) != digest
|
| 499 |
+
)
|
| 500 |
+
extra_manifest = sorted(path for path in manifest_files if path not in expected_manifest)
|
| 501 |
+
stale_detail = (
|
| 502 |
+
f"missing={len(missing_manifest)} changed={len(changed_manifest)} extra={len(extra_manifest)}"
|
| 503 |
+
)
|
| 504 |
+
add(
|
| 505 |
+
checks,
|
| 506 |
+
"HF Space",
|
| 507 |
+
"bundle manifest matches source",
|
| 508 |
+
"PASS" if not missing_manifest and not changed_manifest else "FAIL",
|
| 509 |
+
stale_detail,
|
| 510 |
+
)
|
| 511 |
+
else:
|
| 512 |
+
add(checks, "HF Space", "bundle manifest matches source", "FAIL", "missing or invalid manifest")
|
| 513 |
+
app_text = (export_dir / "app" / "main.py").read_text(encoding="utf-8", errors="replace") if (export_dir / "app" / "main.py").exists() else ""
|
| 514 |
+
static_text = (
|
| 515 |
+
(export_dir / "static" / "app.js").read_text(encoding="utf-8", errors="replace")
|
| 516 |
+
if (export_dir / "static" / "app.js").exists()
|
| 517 |
+
else ""
|
| 518 |
+
)
|
| 519 |
+
verify_site_text = (
|
| 520 |
+
(export_dir / "scripts" / "verify_site.py").read_text(encoding="utf-8", errors="replace")
|
| 521 |
+
if (export_dir / "scripts" / "verify_site.py").exists()
|
| 522 |
+
else ""
|
| 523 |
+
)
|
| 524 |
+
qari_text = (
|
| 525 |
+
(export_dir / "scripts" / "qari_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 526 |
+
if (export_dir / "scripts" / "qari_ocr_extract.py").exists()
|
| 527 |
+
else ""
|
| 528 |
+
)
|
| 529 |
+
qari_default = "Qari-OCR-0.4.0-VL-4B-Instruct"
|
| 530 |
+
tawkeed_text = (
|
| 531 |
+
(export_dir / "scripts" / "tawkeed_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 532 |
+
if (export_dir / "scripts" / "tawkeed_ocr_extract.py").exists()
|
| 533 |
+
else ""
|
| 534 |
+
)
|
| 535 |
+
tawkeed_default = "tawkeed-sa/tawkeed-ocr"
|
| 536 |
+
katib_text = (
|
| 537 |
+
(export_dir / "scripts" / "katib_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 538 |
+
if (export_dir / "scripts" / "katib_ocr_extract.py").exists()
|
| 539 |
+
else ""
|
| 540 |
+
)
|
| 541 |
+
katib_default = "Katib-Qwen3.5-0.8B-0.1"
|
| 542 |
+
arabic_qwen_text = (
|
| 543 |
+
(export_dir / "scripts" / "arabic_qwen_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 544 |
+
if (export_dir / "scripts" / "arabic_qwen_ocr_extract.py").exists()
|
| 545 |
+
else ""
|
| 546 |
+
)
|
| 547 |
+
arabic_qwen_requirements = (
|
| 548 |
+
(export_dir / "requirements-arabic-qwen-ocr.txt").read_text(encoding="utf-8", errors="replace")
|
| 549 |
+
if (export_dir / "requirements-arabic-qwen-ocr.txt").exists()
|
| 550 |
+
else ""
|
| 551 |
+
)
|
| 552 |
+
arabic_qwen_default = "Arabic-Qwen3.5-OCR-v4"
|
| 553 |
+
arabic_glm_text = (
|
| 554 |
+
(export_dir / "scripts" / "arabic_glm_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 555 |
+
if (export_dir / "scripts" / "arabic_glm_ocr_extract.py").exists()
|
| 556 |
+
else ""
|
| 557 |
+
)
|
| 558 |
+
arabic_glm_default = "Arabic-GLM-OCR-v2"
|
| 559 |
+
baseer_text = (
|
| 560 |
+
(export_dir / "scripts" / "baseer_ocr_extract.py").read_text(encoding="utf-8", errors="replace")
|
| 561 |
+
if (export_dir / "scripts" / "baseer_ocr_extract.py").exists()
|
| 562 |
+
else ""
|
| 563 |
+
)
|
| 564 |
+
baseer_default = "Baseer-OCR-V1.0"
|
| 565 |
+
add(
|
| 566 |
+
checks,
|
| 567 |
+
"HF Space",
|
| 568 |
+
"bundle uses QARI-OCR 0.4 default",
|
| 569 |
+
"PASS" if qari_default in app_text and qari_default in qari_text else "FAIL",
|
| 570 |
+
qari_default,
|
| 571 |
+
)
|
| 572 |
+
add(
|
| 573 |
+
checks,
|
| 574 |
+
"HF Space",
|
| 575 |
+
"bundle uses Tawkeed Arabic OCR default",
|
| 576 |
+
"PASS" if tawkeed_default in app_text and tawkeed_default in tawkeed_text else "FAIL",
|
| 577 |
+
tawkeed_default,
|
| 578 |
+
)
|
| 579 |
+
add(
|
| 580 |
+
checks,
|
| 581 |
+
"HF Space",
|
| 582 |
+
"bundle uses KATIB Arabic OCR default",
|
| 583 |
+
"PASS" if katib_default in app_text and katib_default in katib_text else "FAIL",
|
| 584 |
+
katib_default,
|
| 585 |
+
)
|
| 586 |
+
add(
|
| 587 |
+
checks,
|
| 588 |
+
"HF Space",
|
| 589 |
+
"bundle uses Arabic-Qwen3.5 OCR default",
|
| 590 |
+
"PASS" if arabic_qwen_default in app_text and arabic_qwen_default in arabic_qwen_text else "FAIL",
|
| 591 |
+
arabic_qwen_default,
|
| 592 |
+
)
|
| 593 |
+
add(
|
| 594 |
+
checks,
|
| 595 |
+
"HF Space",
|
| 596 |
+
"bundle has Arabic-Qwen3.5 model-specific runner",
|
| 597 |
+
"PASS"
|
| 598 |
+
if all(
|
| 599 |
+
marker in arabic_qwen_text
|
| 600 |
+
for marker in ["Qwen3_5ForConditionalGeneration", "process_vision_info", "trust_remote_code=True"]
|
| 601 |
+
)
|
| 602 |
+
and "qwen-vl-utils" in arabic_qwen_requirements
|
| 603 |
+
else "FAIL",
|
| 604 |
+
"Qwen3_5 loader plus qwen-vl-utils",
|
| 605 |
+
)
|
| 606 |
+
add(
|
| 607 |
+
checks,
|
| 608 |
+
"HF Space",
|
| 609 |
+
"bundle uses Arabic-GLM OCR default",
|
| 610 |
+
"PASS" if arabic_glm_default in app_text and arabic_glm_default in arabic_glm_text else "FAIL",
|
| 611 |
+
arabic_glm_default,
|
| 612 |
+
)
|
| 613 |
+
add(
|
| 614 |
+
checks,
|
| 615 |
+
"HF Space",
|
| 616 |
+
"bundle uses Baseer OCR default",
|
| 617 |
+
"PASS" if baseer_default in app_text and baseer_default in baseer_text else "FAIL",
|
| 618 |
+
baseer_default,
|
| 619 |
+
)
|
| 620 |
+
bundle_contract_markers = {
|
| 621 |
+
"bundle health exposes productionReady": (app_text, '"productionReady"'),
|
| 622 |
+
"bundle health exposes nextAction": (app_text, '"nextAction"'),
|
| 623 |
+
"bundle health rejects direct cloud fallback readiness": (app_text, "not direct_cloud_fallback"),
|
| 624 |
+
"bundle UI shows deployment next action": (static_text, "deploymentStatus.nextAction"),
|
| 625 |
+
"bundle UI warns when productionReady false": (static_text, "deploymentStatus.productionReady === false"),
|
| 626 |
+
"bundle verifier checks productionReady": (verify_site_text, "site production worker ready"),
|
| 627 |
+
"bundle verifier checks worker diagnostics": (verify_site_text, "site worker reachable from vercel"),
|
| 628 |
+
"bundle verifier checks worker CORS": (verify_site_text, "site worker CORS ready"),
|
| 629 |
+
}
|
| 630 |
+
for name, (text, marker) in bundle_contract_markers.items():
|
| 631 |
+
add(
|
| 632 |
+
checks,
|
| 633 |
+
"HF Space",
|
| 634 |
+
name,
|
| 635 |
+
"PASS" if marker in text else "FAIL",
|
| 636 |
+
marker if marker in text else f"missing marker: {marker}",
|
| 637 |
+
)
|
| 638 |
+
for relative in forbidden:
|
| 639 |
+
path = export_dir / relative
|
| 640 |
+
add(checks, "HF Space", f"bundle excludes {relative}", "PASS" if not path.exists() else "FAIL", "excluded" if not path.exists() else "present")
|
| 641 |
+
return checks
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def collect_checks(root: Path = ROOT_DIR) -> list[Check]:
|
| 645 |
+
checks: list[Check] = []
|
| 646 |
+
checks.extend(check_required_files(root))
|
| 647 |
+
checks.extend(check_vercel(root))
|
| 648 |
+
checks.extend(check_worker(root))
|
| 649 |
+
checks.extend(check_ignore_files(root))
|
| 650 |
+
checks.extend(check_deployment_handoff(root))
|
| 651 |
+
checks.extend(check_deployment_quickstart(root))
|
| 652 |
+
checks.extend(check_env_validator(root))
|
| 653 |
+
checks.extend(check_app_contract(root))
|
| 654 |
+
checks.extend(check_hf_space_export(root))
|
| 655 |
+
return checks
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def summarize(checks: list[Check]) -> dict[str, object]:
|
| 659 |
+
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}
|
| 660 |
+
for check in checks:
|
| 661 |
+
counts[check.status] += 1
|
| 662 |
+
return {
|
| 663 |
+
"ready": counts["FAIL"] == 0,
|
| 664 |
+
"counts": counts,
|
| 665 |
+
"checks": [check.__dict__ for check in checks],
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def print_table(checks: list[Check]) -> None:
|
| 670 |
+
for check in checks:
|
| 671 |
+
print(f"{check.status:<4} {check.category:<8} {check.name:<36} {check.detail}")
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def main() -> None:
|
| 675 |
+
parser = argparse.ArgumentParser(description="Check Vercel and Docker worker deployment files before deploying.")
|
| 676 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact table.")
|
| 677 |
+
args = parser.parse_args()
|
| 678 |
+
|
| 679 |
+
checks = collect_checks()
|
| 680 |
+
if args.json:
|
| 681 |
+
print(json.dumps(summarize(checks), indent=2))
|
| 682 |
+
else:
|
| 683 |
+
print_table(checks)
|
| 684 |
+
if any(check.status == "FAIL" for check in checks):
|
| 685 |
+
raise SystemExit(1)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
if __name__ == "__main__":
|
| 689 |
+
main()
|
scripts/check_research_sources.py
ADDED
|
@@ -0,0 +1,1213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import asdict, dataclass
|
| 8 |
+
from datetime import date, datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from urllib.error import HTTPError, URLError
|
| 11 |
+
from urllib.request import Request, urlopen
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 15 |
+
DEFAULT_DOCS = [
|
| 16 |
+
ROOT_DIR / "docs" / "best-free-arabic-pdf-audio-stack.md",
|
| 17 |
+
ROOT_DIR / "docs" / "source-evidence.md",
|
| 18 |
+
ROOT_DIR / "docs" / "huggingface-model-metadata.md",
|
| 19 |
+
ROOT_DIR / "docs" / "live-deployment-checklist.md",
|
| 20 |
+
ROOT_DIR / "docs" / "production-worker-architecture.md",
|
| 21 |
+
ROOT_DIR / "docs" / "research-watchlist.md",
|
| 22 |
+
ROOT_DIR / "docs" / "recommended-free-stack.md",
|
| 23 |
+
ROOT_DIR / "docs" / "recommended-decision-card.md",
|
| 24 |
+
ROOT_DIR / "docs" / "recommended-decision-card.json",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_env_file(path: Path) -> None:
|
| 29 |
+
if not path.exists():
|
| 30 |
+
return
|
| 31 |
+
for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
| 32 |
+
line = raw_line.strip()
|
| 33 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 34 |
+
continue
|
| 35 |
+
key, value = line.split("=", 1)
|
| 36 |
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
load_env_file(ROOT_DIR / ".env")
|
| 40 |
+
URL_RE = re.compile(r"https?://[^\s)\]>`]+")
|
| 41 |
+
REQUIRED_SOURCE_MARKERS = [
|
| 42 |
+
"QARI-OCR",
|
| 43 |
+
"QARI-OCR 0.4",
|
| 44 |
+
"Qari-OCR-0.4.0-VL-4B-Instruct",
|
| 45 |
+
"no hosted inference provider",
|
| 46 |
+
"worker runtime",
|
| 47 |
+
"QARI-OCR 0.4 GGUF",
|
| 48 |
+
"marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF",
|
| 49 |
+
"KATIB 0.8B",
|
| 50 |
+
"Katib-Qwen3.5-0.8B-0.1",
|
| 51 |
+
"Ketaba-OCR LoRA",
|
| 52 |
+
"HassanB4/Ketaba-OCR-LoRA",
|
| 53 |
+
"Qari-OCR-LoRA",
|
| 54 |
+
"HassanB4/Qari-OCR-LoRA",
|
| 55 |
+
"Tawkeed OCR",
|
| 56 |
+
"tawkeed-sa/tawkeed-ocr",
|
| 57 |
+
"PaddleOCR-VL",
|
| 58 |
+
"PaddlePaddle/PaddleOCR-VL-1.6",
|
| 59 |
+
"oi-OCR",
|
| 60 |
+
"oi-uae/oi-OCR",
|
| 61 |
+
"SILMA TTS",
|
| 62 |
+
"silma-ai/silma-tts",
|
| 63 |
+
"Apache-2.0 model weights",
|
| 64 |
+
"SILMA open source Arabic TTS models",
|
| 65 |
+
"SILMA Arabic TTS benchmark",
|
| 66 |
+
"SILMA Hugging Face launch article",
|
| 67 |
+
"Habibi-TTS",
|
| 68 |
+
"Habibi-TTS paper",
|
| 69 |
+
"2601.13802",
|
| 70 |
+
"specialized MSA model is Apache-2.0",
|
| 71 |
+
"Mishkala Tashkeel",
|
| 72 |
+
"flokymind/mishkala",
|
| 73 |
+
"Tashkeel-350M",
|
| 74 |
+
"Etherll/Tashkeel-350M",
|
| 75 |
+
"Mushkil",
|
| 76 |
+
"riotu-lab/mushkil",
|
| 77 |
+
"Thaka KSAA-2026 speech diacritization",
|
| 78 |
+
"2605.25928",
|
| 79 |
+
"KSAA-2026",
|
| 80 |
+
"research signal only",
|
| 81 |
+
"3arab-TTS 500M",
|
| 82 |
+
"sherif1313/3arab-TTS-500M-v1",
|
| 83 |
+
"3arab-TTS-500M-v1-VoiceDesign",
|
| 84 |
+
"KaniTTS Arabic",
|
| 85 |
+
"nineninesix/kani-tts-400m-ar",
|
| 86 |
+
"Emirati VITS Male",
|
| 87 |
+
"vadimbelsky/emirati-vits-male-1.0",
|
| 88 |
+
"VoxCPM2",
|
| 89 |
+
"openbmb/VoxCPM2",
|
| 90 |
+
"Voxtral TTS",
|
| 91 |
+
"mistralai/Voxtral-4B-TTS-2603",
|
| 92 |
+
"cc-by-nc-4.0",
|
| 93 |
+
"MOSS-TTS-Nano",
|
| 94 |
+
"OpenMOSS/MOSS-TTS-Nano",
|
| 95 |
+
"Supertonic 3",
|
| 96 |
+
"Supertone/supertonic-3",
|
| 97 |
+
"OpenRAIL model",
|
| 98 |
+
"Kyutai Pocket TTS",
|
| 99 |
+
"kyutai.org/tts",
|
| 100 |
+
"not Arabic",
|
| 101 |
+
"Falcon-OCR",
|
| 102 |
+
"tiiuae/Falcon-OCR",
|
| 103 |
+
"Baseer OCR",
|
| 104 |
+
"AbdoTarek/Baseer-OCR-V1.0",
|
| 105 |
+
"Arabic-GLM-OCR-v2",
|
| 106 |
+
"sherif1313/Arabic-GLM-OCR-v2",
|
| 107 |
+
"Arabic-Qwen3.5-OCR-v4",
|
| 108 |
+
"sherif1313/Arabic-Qwen3.5-OCR-v4",
|
| 109 |
+
"aNS Qwen3-VL Arabic OCR v3",
|
| 110 |
+
"aNS2024/qwen3-vl-arabic-ocr-v3",
|
| 111 |
+
"Waraqon v3 Arabic OCR HTML Qari",
|
| 112 |
+
"FatimahEmadEldin/Waraqon-v3-Arabic-OCR-HTML-Qari",
|
| 113 |
+
"DeepSeek-OCR-2",
|
| 114 |
+
"deepseek-ai/DeepSeek-OCR-2",
|
| 115 |
+
"DeepSeek Arabic OCR v6",
|
| 116 |
+
"melsiddieg/deepseek_ocr_arabic_v6",
|
| 117 |
+
"Loay Arabic-OCR-DeepSeek-OCR-2",
|
| 118 |
+
"loay/Arabic-OCR-DeepSeek-OCR-2",
|
| 119 |
+
"Arabic-English handwritten OCR Qwen3-VL",
|
| 120 |
+
"sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B",
|
| 121 |
+
"Arabic-English handwritten OCR v3",
|
| 122 |
+
"sherif1313/Arabic-English-handwritten-OCR-v3",
|
| 123 |
+
"Arabic handwritten OCR 4-bit Qwen2.5-VL",
|
| 124 |
+
"sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3",
|
| 125 |
+
"NAKBA Arabic manuscript line OCR baseline",
|
| 126 |
+
"U4RASD/ar-ms-baseline",
|
| 127 |
+
"HAFITH",
|
| 128 |
+
"mdnaseif/hafith",
|
| 129 |
+
"Glimpse RTL OCR",
|
| 130 |
+
"surfiniaburger/unsloth_finetune_ocr_arabic",
|
| 131 |
+
"Arabic OCR Qwen2.5-VL GGUF",
|
| 132 |
+
"mo1998/arabic-ocr-qwen2.5-vl",
|
| 133 |
+
"Qwen3-VL Persian/Arabic line OCR",
|
| 134 |
+
"mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0",
|
| 135 |
+
"DIMI Arabic OCR v2",
|
| 136 |
+
"AhmedZaky1/DIMI-Arabic-OCR-V2",
|
| 137 |
+
"Raqim post-OCR correction",
|
| 138 |
+
"Arabic Legal Documents OCR 1.0",
|
| 139 |
+
"bakrianoo/arabic-legal-documents-ocr-1.0",
|
| 140 |
+
"Loay Arabic-OCR-Qwen2.5-VL-7B",
|
| 141 |
+
"loay/Arabic-OCR-Qwen2.5-VL-7B-Vision",
|
| 142 |
+
"AtlasOCR",
|
| 143 |
+
"atlasia/AtlasOCR",
|
| 144 |
+
"NuExtract3",
|
| 145 |
+
"numind/NuExtract3",
|
| 146 |
+
"Qianfan-OCR",
|
| 147 |
+
"baidu/Qianfan-OCR",
|
| 148 |
+
"Chandra OCR 2",
|
| 149 |
+
"datalab-to/chandra",
|
| 150 |
+
"dots.ocr",
|
| 151 |
+
"rednote-hilab/dots.ocr",
|
| 152 |
+
"olmOCR Arabic LoRA v2",
|
| 153 |
+
"hastyle/olmOCR-arabic-lora-v2",
|
| 154 |
+
"Arabic Large Nougat",
|
| 155 |
+
"MohamedRashad/arabic-large-nougat",
|
| 156 |
+
"DocTR Arabic FAST/PARSEQ",
|
| 157 |
+
"madskills/doctr-fast_base-arabic",
|
| 158 |
+
"madskills/doctr-parseq-arabic",
|
| 159 |
+
"Kraken/eScriptorium Arabic script",
|
| 160 |
+
"kraken.re/main/index.html",
|
| 161 |
+
"escriptorium.eu/about",
|
| 162 |
+
"Kairawan/Qalamus manuscript OCR",
|
| 163 |
+
"kairawan.org",
|
| 164 |
+
"GLM-OCR Arabic/French documents",
|
| 165 |
+
"maloukafer/GLM-OCR-finetuned-documents",
|
| 166 |
+
"mimoha Arabic OCR",
|
| 167 |
+
"mimoha/ocr",
|
| 168 |
+
"OmniVoice",
|
| 169 |
+
"k2-fsa/OmniVoice",
|
| 170 |
+
"OmniVoice Arabic LoRA",
|
| 171 |
+
"vivooglobal/omnivoice-lora-ar",
|
| 172 |
+
"Arabic-text-to-speech OmniVoice",
|
| 173 |
+
"bilalRHCH/Arabic-text-to-speech",
|
| 174 |
+
"Lahgtna OmniVoice v2",
|
| 175 |
+
"oddadmix/lahgtna-omnivoice-v2",
|
| 176 |
+
"TADA multilingual TTS",
|
| 177 |
+
"HumeAI/tada-3b-ml",
|
| 178 |
+
"Lahgtna Chatterbox",
|
| 179 |
+
"oddadmix/lahgtna-chatterbox-v1",
|
| 180 |
+
"NAMAA-Saudi-TTS",
|
| 181 |
+
"NAMAA-Space/NAMAA-Saudi-TTS",
|
| 182 |
+
"NAMAA-Egyptian-TTS",
|
| 183 |
+
"NAMAA-Space/NAMAA-Egyptian-TTS",
|
| 184 |
+
"Saudi Chatterbox fine-tune",
|
| 185 |
+
"FatimahEmadEldin/saudi-tts-chatterbox-finetuned",
|
| 186 |
+
"Saudi TTS",
|
| 187 |
+
"AhmedEladl/saudi-tts",
|
| 188 |
+
"Egyptian Arabic Chatterbox",
|
| 189 |
+
"AliAbdallah/egyptian-arabic-tts-chatterbox",
|
| 190 |
+
"NileTTS-XTTS",
|
| 191 |
+
"KickItLikeShika/NileTTS-XTTS",
|
| 192 |
+
"Arabic XTTS-v2 Egyptian fine-tune",
|
| 193 |
+
"Moeeldouma/arabic-tts-xtts-v2",
|
| 194 |
+
"Coqui Public Model License",
|
| 195 |
+
"Chatterbox-Multilingual",
|
| 196 |
+
"resemble-ai/chatterbox",
|
| 197 |
+
"Chatterbox Arabic fine-tune",
|
| 198 |
+
"juliardi/chatterbox-multilingual-finetuned-arabic",
|
| 199 |
+
"Chatterbox-Multilingual ONNX",
|
| 200 |
+
"onnx-community/chatterbox-multilingual-ONNX",
|
| 201 |
+
"tts-arabic-onnx",
|
| 202 |
+
"nipponjo/tts-arabic-onnx",
|
| 203 |
+
"Spark-TTS Arabic",
|
| 204 |
+
"azeddinShr/Spark-TTS-Arabic-Complete",
|
| 205 |
+
"Sofelia-TTS",
|
| 206 |
+
"hamdallah/Sofelia-TTS",
|
| 207 |
+
"Arabic-F5-TTS-v2",
|
| 208 |
+
"IbrahimSalah/Arabic-F5-TTS-v2",
|
| 209 |
+
"Qwen3-TTS",
|
| 210 |
+
"Qwen3-TTS-12Hz-0.6B-Base",
|
| 211 |
+
"Qwen3-TTS-12Hz-1.7B-Base",
|
| 212 |
+
"Egyptian Arabic Qwen3-TTS",
|
| 213 |
+
"itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base",
|
| 214 |
+
"Saudi Arabic Qwen3-TTS",
|
| 215 |
+
"vadimbelsky/qwen3-TTS-KSA",
|
| 216 |
+
"Emirati Qwen3.5-TTS",
|
| 217 |
+
"vadimbelsky/qwen3.5-TTS-Emirati",
|
| 218 |
+
"MMS Arabic TTS",
|
| 219 |
+
"Vercel FastAPI",
|
| 220 |
+
"Vercel Blob usage and pricing",
|
| 221 |
+
"Vercel Functions limits",
|
| 222 |
+
"4.5 MB request/response body limit",
|
| 223 |
+
"Hugging Face Docker Spaces",
|
| 224 |
+
"Hugging Face Hub storage limits",
|
| 225 |
+
"2 vCPU",
|
| 226 |
+
"16 GB RAM",
|
| 227 |
+
"50 GB non-persistent disk",
|
| 228 |
+
]
|
| 229 |
+
MAX_METADATA_AGE_DAYS = 30
|
| 230 |
+
REQUIRED_METADATA_MARKERS = [
|
| 231 |
+
"NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct",
|
| 232 |
+
"silma-ai/silma-tts",
|
| 233 |
+
"sherif1313/Arabic-Qwen3.5-OCR-v4",
|
| 234 |
+
"deepseek-ai/DeepSeek-OCR-2",
|
| 235 |
+
"melsiddieg/deepseek_ocr_arabic_v6",
|
| 236 |
+
"sherif1313/Arabic-GLM-OCR-v2",
|
| 237 |
+
"sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B",
|
| 238 |
+
"sherif1313/Arabic-English-handwritten-OCR-v3",
|
| 239 |
+
"mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0",
|
| 240 |
+
"bakrianoo/arabic-legal-documents-ocr-1.0",
|
| 241 |
+
"oi-uae/oi-OCR",
|
| 242 |
+
"NAMAA-Space/NAMAA-Saudi-TTS",
|
| 243 |
+
"AhmedEladl/saudi-tts",
|
| 244 |
+
"AliAbdallah/egyptian-arabic-tts-chatterbox",
|
| 245 |
+
"KickItLikeShika/NileTTS-XTTS",
|
| 246 |
+
"Moeeldouma/arabic-tts-xtts-v2",
|
| 247 |
+
"onnx-community/chatterbox-multilingual-ONNX",
|
| 248 |
+
"itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base",
|
| 249 |
+
"vadimbelsky/qwen3-TTS-KSA",
|
| 250 |
+
"vadimbelsky/qwen3.5-TTS-Emirati",
|
| 251 |
+
"sherif1313/3arab-TTS-500M-v1-VoiceDesign",
|
| 252 |
+
"numind/NuExtract3",
|
| 253 |
+
"baidu/Qianfan-OCR",
|
| 254 |
+
"datalab-to/chandra",
|
| 255 |
+
"rednote-hilab/dots.ocr",
|
| 256 |
+
"MohamedRashad/arabic-large-nougat",
|
| 257 |
+
"apache-2.0",
|
| 258 |
+
"gpl-3.0",
|
| 259 |
+
"cc-by-nc-4.0",
|
| 260 |
+
"fair-noncommercial-research-license",
|
| 261 |
+
"coqui-public-model-license",
|
| 262 |
+
"openrail",
|
| 263 |
+
"Rows marked `page-only` use verified public model-page evidence",
|
| 264 |
+
]
|
| 265 |
+
REQUIRED_RECOMMENDATION_MARKERS = [
|
| 266 |
+
"Recommended Free Arabic PDF To Audio Stack",
|
| 267 |
+
"PyMuPDF text extraction first",
|
| 268 |
+
"`OCR_ENGINE=arabic-max`",
|
| 269 |
+
"SILMA TTS",
|
| 270 |
+
"Vercel shell plus Docker worker",
|
| 271 |
+
"Benchmark Before Promoting",
|
| 272 |
+
"model_promotion_gate.py",
|
| 273 |
+
"PyMuPDF -> `arabic-max` OCR -> SILMA TTS",
|
| 274 |
+
]
|
| 275 |
+
REQUIRED_DECISION_CARD_MARKERS = [
|
| 276 |
+
"Recommended Free Arabic PDF To Audio Decision Card",
|
| 277 |
+
"PyMuPDF embedded text first",
|
| 278 |
+
"OCR_ENGINE=arabic-max",
|
| 279 |
+
"SILMA TTS",
|
| 280 |
+
"worker-local retained downloads",
|
| 281 |
+
"Vercel shell plus Docker worker",
|
| 282 |
+
"model_promotion_gate.py",
|
| 283 |
+
"scoreJsonRequired",
|
| 284 |
+
]
|
| 285 |
+
REQUIRED_WATCHLIST_COMMAND_MARKERS = [
|
| 286 |
+
"model_promotion_gate.py",
|
| 287 |
+
"score_external_ocr.py",
|
| 288 |
+
"score_voice_listening.py",
|
| 289 |
+
"score_tts_preprocessor.py",
|
| 290 |
+
"--write-json outputs\\external-ocr-sample\\external-ocr-score.json",
|
| 291 |
+
"--write-json outputs\\external-tts-sample\\voice-listening-score.json",
|
| 292 |
+
"--write-json outputs\\external-tts-sample\\tts-preprocessor-score.json",
|
| 293 |
+
"--candidate oi-ocr=outputs\\external-ocr-sample\\oi-ocr.txt",
|
| 294 |
+
"--kind ocr",
|
| 295 |
+
"--kind tts",
|
| 296 |
+
"--kind preprocessor",
|
| 297 |
+
]
|
| 298 |
+
WORKFLOW_DOC_PATHS = [
|
| 299 |
+
ROOT_DIR / "README.md",
|
| 300 |
+
ROOT_DIR / "docs" / "best-free-arabic-pdf-audio-stack.md",
|
| 301 |
+
]
|
| 302 |
+
REQUIRED_WORKFLOW_MARKERS = [
|
| 303 |
+
"QARI-OCR 0.4 GGUF",
|
| 304 |
+
"Loay Arabic-OCR-DeepSeek-OCR-2",
|
| 305 |
+
"NAMAA-Egyptian-TTS",
|
| 306 |
+
"Chatterbox Arabic fine-tune",
|
| 307 |
+
"not served as a simple hosted Hugging Face inference route",
|
| 308 |
+
"not deployed by a hosted inference provider",
|
| 309 |
+
"--candidate qari-gguf=outputs\\external-ocr-sample\\qari-gguf.txt",
|
| 310 |
+
"--candidate loay-deepseek-ocr-2=outputs\\external-ocr-sample\\loay-deepseek-ocr-2.txt",
|
| 311 |
+
"outputs\\external-tts-sample\\arabic-tts-sample.txt",
|
| 312 |
+
"model_promotion_gate.py",
|
| 313 |
+
]
|
| 314 |
+
KEY_SOURCE_URLS = {
|
| 315 |
+
"QARI-OCR 0.4 model": "https://huggingface.co/NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct",
|
| 316 |
+
"QARI-OCR 0.4 GGUF model": "https://huggingface.co/marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF",
|
| 317 |
+
"KATIB Arabic OCR model": "https://huggingface.co/oddadmix/Katib-Qwen3.5-0.8B-0.1",
|
| 318 |
+
"Ketaba-OCR LoRA model": "https://huggingface.co/HassanB4/Ketaba-OCR-LoRA",
|
| 319 |
+
"Qari-OCR-LoRA model": "https://huggingface.co/HassanB4/Qari-OCR-LoRA",
|
| 320 |
+
"Tawkeed Arabic OCR model": "https://huggingface.co/tawkeed-sa/tawkeed-ocr",
|
| 321 |
+
"PaddleOCR-VL 1.6 model": "https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6",
|
| 322 |
+
"oi-OCR model": "https://huggingface.co/oi-uae/oi-OCR",
|
| 323 |
+
"Qianfan-OCR model": "https://huggingface.co/baidu/Qianfan-OCR",
|
| 324 |
+
"PaddleOCR latest docs": "https://www.paddleocr.ai/latest/en/index.html",
|
| 325 |
+
"SILMA TTS model": "https://huggingface.co/silma-ai/silma-tts",
|
| 326 |
+
"SILMA open source Arabic TTS models": "https://silma.ai/open-source-arabic-tts-models",
|
| 327 |
+
"SILMA Arabic TTS benchmark": "https://silma.ai/arabic-tts-benchmark",
|
| 328 |
+
"SILMA Hugging Face launch article": "https://huggingface.co/blog/silma-ai/opensource-arabic-english-text-to-speech-model",
|
| 329 |
+
"Habibi-TTS repository": "https://github.com/SWivid/Habibi-TTS",
|
| 330 |
+
"Habibi-TTS paper": "https://arxiv.org/abs/2601.13802",
|
| 331 |
+
"Mishkala Tashkeel model": "https://huggingface.co/flokymind/mishkala",
|
| 332 |
+
"Tashkeel 350M model": "https://huggingface.co/Etherll/Tashkeel-350M",
|
| 333 |
+
"Mushkil model": "https://huggingface.co/riotu-lab/mushkil",
|
| 334 |
+
"Thaka KSAA 2026 speech diacritization paper": "https://arxiv.org/abs/2605.25928",
|
| 335 |
+
"KSAA 2026 shared task": "https://www.codabench.org/competitions/11859/",
|
| 336 |
+
"3arab-TTS 500M model": "https://huggingface.co/sherif1313/3arab-TTS-500M-v1",
|
| 337 |
+
"3arab-TTS 500M VoiceDesign model": "https://huggingface.co/sherif1313/3arab-TTS-500M-v1-VoiceDesign",
|
| 338 |
+
"KaniTTS Arabic model": "https://huggingface.co/nineninesix/kani-tts-400m-ar",
|
| 339 |
+
"Emirati VITS Male model": "https://huggingface.co/vadimbelsky/emirati-vits-male-1.0",
|
| 340 |
+
"VoxCPM2 model": "https://huggingface.co/openbmb/VoxCPM2",
|
| 341 |
+
"VoxCPM paper": "https://arxiv.org/abs/2509.24650",
|
| 342 |
+
"Voxtral TTS model": "https://huggingface.co/mistralai/Voxtral-4B-TTS-2603",
|
| 343 |
+
"Voxtral TTS paper": "https://arxiv.org/abs/2603.25551",
|
| 344 |
+
"MOSS-TTS-Nano repository": "https://github.com/OpenMOSS/MOSS-TTS-Nano",
|
| 345 |
+
"Supertonic 3 model": "https://huggingface.co/Supertone/supertonic-3",
|
| 346 |
+
"Kyutai TTS official page": "https://kyutai.org/tts",
|
| 347 |
+
"Falcon OCR model": "https://huggingface.co/tiiuae/Falcon-OCR",
|
| 348 |
+
"Falcon Perception paper": "https://arxiv.org/abs/2603.27365",
|
| 349 |
+
"Baseer OCR model": "https://huggingface.co/AbdoTarek/Baseer-OCR-V1.0",
|
| 350 |
+
"Arabic GLM OCR v2 model": "https://huggingface.co/sherif1313/Arabic-GLM-OCR-v2",
|
| 351 |
+
"Arabic Qwen3.5 OCR v4 model": "https://huggingface.co/sherif1313/Arabic-Qwen3.5-OCR-v4",
|
| 352 |
+
"aNS Qwen3 VL Arabic OCR v3 model": "https://huggingface.co/aNS2024/qwen3-vl-arabic-ocr-v3",
|
| 353 |
+
"Waraqon v3 Arabic OCR HTML Qari model": "https://huggingface.co/FatimahEmadEldin/Waraqon-v3-Arabic-OCR-HTML-Qari",
|
| 354 |
+
"DeepSeek OCR 2 model": "https://huggingface.co/deepseek-ai/DeepSeek-OCR-2",
|
| 355 |
+
"DeepSeek Arabic OCR v6 model": "https://huggingface.co/melsiddieg/deepseek_ocr_arabic_v6",
|
| 356 |
+
"Loay Arabic DeepSeek OCR 2 model": "https://huggingface.co/loay/Arabic-OCR-DeepSeek-OCR-2",
|
| 357 |
+
"Arabic-English handwritten OCR Qwen3-VL model": "https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B",
|
| 358 |
+
"Arabic-English handwritten OCR v3 model": "https://huggingface.co/sherif1313/Arabic-English-handwritten-OCR-v3",
|
| 359 |
+
"Arabic handwritten OCR 4-bit Qwen2.5 VL model": "https://huggingface.co/sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3",
|
| 360 |
+
"NAKBA Arabic manuscript line OCR baseline": "https://huggingface.co/U4RASD/ar-ms-baseline",
|
| 361 |
+
"HAFITH model": "https://huggingface.co/mdnaseif/hafith",
|
| 362 |
+
"Glimpse RTL OCR model": "https://huggingface.co/surfiniaburger/unsloth_finetune_ocr_arabic",
|
| 363 |
+
"Arabic OCR Qwen2.5 VL GGUF model": "https://huggingface.co/mo1998/arabic-ocr-qwen2.5-vl",
|
| 364 |
+
"Qwen3-VL Persian Arabic line OCR model": "https://huggingface.co/mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0",
|
| 365 |
+
"DIMI Arabic OCR v2 model": "https://huggingface.co/AhmedZaky1/DIMI-Arabic-OCR-V2",
|
| 366 |
+
"Loay Arabic OCR Qwen2.5 VL 7B model": "https://huggingface.co/loay/Arabic-OCR-Qwen2.5-VL-7B-Vision",
|
| 367 |
+
"Arabic Legal Documents OCR 1.0 model": "https://huggingface.co/bakrianoo/arabic-legal-documents-ocr-1.0",
|
| 368 |
+
"AtlasOCR model": "https://huggingface.co/atlasia/AtlasOCR",
|
| 369 |
+
"NuExtract3 model": "https://huggingface.co/numind/NuExtract3",
|
| 370 |
+
"Chandra OCR repository": "https://github.com/datalab-to/chandra",
|
| 371 |
+
"dots.ocr model": "https://huggingface.co/rednote-hilab/dots.ocr",
|
| 372 |
+
"olmOCR Arabic LoRA v2 model": "https://huggingface.co/hastyle/olmOCR-arabic-lora-v2",
|
| 373 |
+
"Arabic Large Nougat model": "https://huggingface.co/MohamedRashad/arabic-large-nougat",
|
| 374 |
+
"DocTR Arabic FAST detector": "https://huggingface.co/madskills/doctr-fast_base-arabic",
|
| 375 |
+
"DocTR Arabic PARSEQ recognizer": "https://huggingface.co/madskills/doctr-parseq-arabic",
|
| 376 |
+
"Kraken OCR documentation": "https://kraken.re/main/index.html",
|
| 377 |
+
"eScriptorium overview": "https://escriptorium.eu/about",
|
| 378 |
+
"Kairawan manuscript OCR": "https://kairawan.org/",
|
| 379 |
+
"GLM-OCR Arabic French documents model": "https://huggingface.co/maloukafer/GLM-OCR-finetuned-documents",
|
| 380 |
+
"mimoha Arabic OCR model": "https://huggingface.co/mimoha/ocr",
|
| 381 |
+
"OmniVoice model": "https://huggingface.co/k2-fsa/OmniVoice",
|
| 382 |
+
"OmniVoice Arabic LoRA": "https://huggingface.co/vivooglobal/omnivoice-lora-ar",
|
| 383 |
+
"Arabic text to speech OmniVoice model": "https://huggingface.co/bilalRHCH/Arabic-text-to-speech",
|
| 384 |
+
"Lahgtna OmniVoice v2 model": "https://huggingface.co/oddadmix/lahgtna-omnivoice-v2",
|
| 385 |
+
"TADA multilingual TTS model": "https://huggingface.co/HumeAI/tada-3b-ml",
|
| 386 |
+
"Lahgtna Chatterbox model": "https://huggingface.co/oddadmix/lahgtna-chatterbox-v1",
|
| 387 |
+
"NAMAA Saudi TTS model": "https://huggingface.co/NAMAA-Space/NAMAA-Saudi-TTS",
|
| 388 |
+
"NAMAA Egyptian TTS model": "https://huggingface.co/NAMAA-Space/NAMAA-Egyptian-TTS",
|
| 389 |
+
"Saudi Chatterbox fine-tune model": "https://huggingface.co/FatimahEmadEldin/saudi-tts-chatterbox-finetuned",
|
| 390 |
+
"Saudi TTS model": "https://huggingface.co/AhmedEladl/saudi-tts",
|
| 391 |
+
"Egyptian Arabic Chatterbox model": "https://huggingface.co/AliAbdallah/egyptian-arabic-tts-chatterbox",
|
| 392 |
+
"NileTTS XTTS model": "https://huggingface.co/KickItLikeShika/NileTTS-XTTS",
|
| 393 |
+
"Arabic XTTS v2 Egyptian fine-tune model": "https://huggingface.co/Moeeldouma/arabic-tts-xtts-v2",
|
| 394 |
+
"NileTTS paper": "https://arxiv.org/abs/2602.15675",
|
| 395 |
+
"Chatterbox repository": "https://github.com/resemble-ai/chatterbox",
|
| 396 |
+
"Chatterbox Arabic fine-tune model": "https://huggingface.co/juliardi/chatterbox-multilingual-finetuned-arabic",
|
| 397 |
+
"Chatterbox Multilingual ONNX model": "https://huggingface.co/onnx-community/chatterbox-multilingual-ONNX",
|
| 398 |
+
"tts-arabic-onnx model": "https://huggingface.co/nipponjo/tts-arabic-onnx",
|
| 399 |
+
"tts_arabic repository": "https://github.com/nipponjo/tts_arabic",
|
| 400 |
+
"Spark-TTS Arabic model": "https://huggingface.co/azeddinShr/Spark-TTS-Arabic-Complete",
|
| 401 |
+
"Sofelia-TTS model": "https://huggingface.co/hamdallah/Sofelia-TTS",
|
| 402 |
+
"Arabic F5 TTS v2 model": "https://huggingface.co/IbrahimSalah/Arabic-F5-TTS-v2",
|
| 403 |
+
"Qwen3-TTS 0.6B Base": "https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
| 404 |
+
"Qwen3-TTS 1.7B Base": "https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
| 405 |
+
"Egyptian Arabic Qwen3-TTS model": "https://huggingface.co/itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base",
|
| 406 |
+
"Saudi Arabic Qwen3-TTS model": "https://huggingface.co/vadimbelsky/qwen3-TTS-KSA",
|
| 407 |
+
"Emirati Qwen3.5-TTS model": "https://huggingface.co/vadimbelsky/qwen3.5-TTS-Emirati",
|
| 408 |
+
"Qwen3-TTS technical report": "https://arxiv.org/abs/2601.15621",
|
| 409 |
+
"Vercel FastAPI deployment": "https://vercel.com/docs/frameworks/backend/fastapi",
|
| 410 |
+
"Vercel Functions limits": "https://vercel.com/docs/functions/limitations/",
|
| 411 |
+
"Vercel Blob usage and pricing": "https://vercel.com/docs/vercel-blob/usage-and-pricing",
|
| 412 |
+
"Hugging Face Docker Spaces": "https://huggingface.co/docs/hub/main/en/spaces-sdks-docker",
|
| 413 |
+
"Hugging Face Hub storage limits": "https://huggingface.co/docs/hub/main/storage-limits",
|
| 414 |
+
}
|
| 415 |
+
HF_EXPECTED_LICENSES = {
|
| 416 |
+
"NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct": "apache-2.0",
|
| 417 |
+
"marwan-osama/Qari-OCR-0.4.0-VL-4B-Instruct-GGUF": "apache-2.0",
|
| 418 |
+
"oddadmix/Katib-Qwen3.5-0.8B-0.1": "apache-2.0",
|
| 419 |
+
"HassanB4/Ketaba-OCR-LoRA": "apache-2.0",
|
| 420 |
+
"HassanB4/Qari-OCR-LoRA": "apache-2.0",
|
| 421 |
+
"tawkeed-sa/tawkeed-ocr": "apache-2.0",
|
| 422 |
+
"PaddlePaddle/PaddleOCR-VL-1.6": "apache-2.0",
|
| 423 |
+
"oi-uae/oi-OCR": "apache-2.0",
|
| 424 |
+
"madskills/doctr-fast_base-arabic": "apache-2.0",
|
| 425 |
+
"mimoha/ocr": "apache-2.0",
|
| 426 |
+
"silma-ai/silma-tts": "apache-2.0",
|
| 427 |
+
"flokymind/mishkala": "apache-2.0",
|
| 428 |
+
"Etherll/Tashkeel-350M": "apache-2.0",
|
| 429 |
+
"riotu-lab/mushkil": "apache-2.0",
|
| 430 |
+
"sherif1313/3arab-TTS-500M-v1": "apache-2.0",
|
| 431 |
+
"sherif1313/3arab-TTS-500M-v1-VoiceDesign": "apache-2.0",
|
| 432 |
+
"vadimbelsky/emirati-vits-male-1.0": "apache-2.0",
|
| 433 |
+
"openbmb/VoxCPM2": "apache-2.0",
|
| 434 |
+
"mistralai/Voxtral-4B-TTS-2603": "cc-by-nc-4.0",
|
| 435 |
+
"Supertone/supertonic-3": "openrail",
|
| 436 |
+
"baidu/Qianfan-OCR": "apache-2.0",
|
| 437 |
+
"tiiuae/Falcon-OCR": "apache-2.0",
|
| 438 |
+
"AbdoTarek/Baseer-OCR-V1.0": "apache-2.0",
|
| 439 |
+
"sherif1313/Arabic-GLM-OCR-v2": "apache-2.0",
|
| 440 |
+
"sherif1313/Arabic-Qwen3.5-OCR-v4": "apache-2.0",
|
| 441 |
+
"FatimahEmadEldin/Waraqon-v3-Arabic-OCR-HTML-Qari": "apache-2.0",
|
| 442 |
+
"deepseek-ai/DeepSeek-OCR-2": "apache-2.0",
|
| 443 |
+
"melsiddieg/deepseek_ocr_arabic_v6": "apache-2.0",
|
| 444 |
+
"loay/Arabic-OCR-DeepSeek-OCR-2": "apache-2.0",
|
| 445 |
+
"sherif1313/Arabic-English-handwritten-OCR-Qwen3-VL-4B": "apache-2.0",
|
| 446 |
+
"sherif1313/Arabic-English-handwritten-OCR-v3": "apache-2.0",
|
| 447 |
+
"sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3": "apache-2.0",
|
| 448 |
+
"mdnaseif/hafith": "apache-2.0",
|
| 449 |
+
"surfiniaburger/unsloth_finetune_ocr_arabic": "apache-2.0",
|
| 450 |
+
"mohajesmaeili/Qwen3-VL-2B-Persian-Arabic-Ocr-v1.0": "apache-2.0",
|
| 451 |
+
"AhmedZaky1/DIMI-Arabic-OCR-V2": "apache-2.0",
|
| 452 |
+
"hastyle/olmOCR-arabic-lora-v2": "apache-2.0",
|
| 453 |
+
"MohamedRashad/arabic-large-nougat": "gpl-3.0",
|
| 454 |
+
"bakrianoo/arabic-legal-documents-ocr-1.0": "gemma",
|
| 455 |
+
"k2-fsa/OmniVoice": "apache-2.0",
|
| 456 |
+
"bilalRHCH/Arabic-text-to-speech": "apache-2.0",
|
| 457 |
+
"vivooglobal/omnivoice-lora-ar": "apache-2.0",
|
| 458 |
+
"HumeAI/tada-3b-ml": "llama3.2",
|
| 459 |
+
"oddadmix/lahgtna-chatterbox-v1": "mit",
|
| 460 |
+
"NAMAA-Space/NAMAA-Saudi-TTS": "mit",
|
| 461 |
+
"NAMAA-Space/NAMAA-Egyptian-TTS": "mit",
|
| 462 |
+
"FatimahEmadEldin/saudi-tts-chatterbox-finetuned": "apache-2.0",
|
| 463 |
+
"AhmedEladl/saudi-tts": "apache-2.0",
|
| 464 |
+
"AliAbdallah/egyptian-arabic-tts-chatterbox": "apache-2.0",
|
| 465 |
+
"juliardi/chatterbox-multilingual-finetuned-arabic": "mit",
|
| 466 |
+
"KickItLikeShika/NileTTS-XTTS": "apache-2.0",
|
| 467 |
+
"Moeeldouma/arabic-tts-xtts-v2": "coqui-public-model-license",
|
| 468 |
+
"onnx-community/chatterbox-multilingual-ONNX": "mit",
|
| 469 |
+
"azeddinShr/Spark-TTS-Arabic-Complete": "apache-2.0",
|
| 470 |
+
"hamdallah/Sofelia-TTS": "apache-2.0",
|
| 471 |
+
"Qwen/Qwen3-TTS-12Hz-0.6B-Base": "apache-2.0",
|
| 472 |
+
"Qwen/Qwen3-TTS-12Hz-1.7B-Base": "apache-2.0",
|
| 473 |
+
"itshamdi404/Egy_Arabic_Qwen3-TTS-12Hz-1.7B-Base": "apache-2.0",
|
| 474 |
+
"vadimbelsky/qwen3-TTS-KSA": "apache-2.0",
|
| 475 |
+
"vadimbelsky/qwen3.5-TTS-Emirati": "apache-2.0",
|
| 476 |
+
}
|
| 477 |
+
HF_PAGE_ONLY_METADATA = {
|
| 478 |
+
"NAMAA-Space/Qari-OCR-0.4.0-VL-4B-Instruct": {
|
| 479 |
+
"license": "apache-2.0",
|
| 480 |
+
"reason": "Hugging Face model page was verified in research; keep page-only so restricted local sockets do not erase the core Arabic-book OCR evidence.",
|
| 481 |
+
},
|
| 482 |
+
"oddadmix/Katib-Qwen3.5-0.8B-0.1": {
|
| 483 |
+
"license": "apache-2.0",
|
| 484 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is a wired optional Arabic OCR sidecar.",
|
| 485 |
+
},
|
| 486 |
+
"HassanB4/Ketaba-OCR-LoRA": {
|
| 487 |
+
"license": "apache-2.0",
|
| 488 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is an external Arabic manuscript benchmark candidate.",
|
| 489 |
+
},
|
| 490 |
+
"HassanB4/Qari-OCR-LoRA": {
|
| 491 |
+
"license": "apache-2.0",
|
| 492 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is a secondary external QARI-family manuscript benchmark.",
|
| 493 |
+
},
|
| 494 |
+
"silma-ai/silma-tts": {
|
| 495 |
+
"license": "apache-2.0",
|
| 496 |
+
"reason": "Hugging Face model page was verified in research; keep page-only so restricted local sockets do not erase the core Arabic TTS evidence.",
|
| 497 |
+
},
|
| 498 |
+
"NAMAA-Space/NAMAA-Saudi-TTS": {
|
| 499 |
+
"license": "mit",
|
| 500 |
+
"reason": "Hugging Face model page was verified in research, but raw metadata may not be available in restricted environments.",
|
| 501 |
+
},
|
| 502 |
+
"tawkeed-sa/tawkeed-ocr": {
|
| 503 |
+
"license": "apache-2.0",
|
| 504 |
+
"reason": "Hugging Face model page is public, but raw API/HTTP requests can return 401/404 for this namespace.",
|
| 505 |
+
},
|
| 506 |
+
"MohamedRashad/arabic-large-nougat": {
|
| 507 |
+
"license": "gpl-3.0",
|
| 508 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because the public model card is enough for license/status tracking and the model is benchmark-only.",
|
| 509 |
+
},
|
| 510 |
+
"baidu/Qianfan-OCR": {
|
| 511 |
+
"license": "apache-2.0",
|
| 512 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because this large benchmark-only model does not need local metadata fetches to block/promote the default stack.",
|
| 513 |
+
},
|
| 514 |
+
"AhmedEladl/saudi-tts": {
|
| 515 |
+
"license": "apache-2.0",
|
| 516 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is a dialect benchmark candidate, not a default production voice.",
|
| 517 |
+
},
|
| 518 |
+
"Moeeldouma/arabic-tts-xtts-v2": {
|
| 519 |
+
"license": "coqui-public-model-license",
|
| 520 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is an XTTS-v2 dialect benchmark and inherits CPML base-license caution.",
|
| 521 |
+
},
|
| 522 |
+
"datalab-to/chandra": {
|
| 523 |
+
"license": "openrail",
|
| 524 |
+
"reason": "Hugging Face/official project metadata was verified in research; keep page-only because Chandra is benchmark-only and the weights are not the permissive default path.",
|
| 525 |
+
},
|
| 526 |
+
"mistralai/Voxtral-4B-TTS-2603": {
|
| 527 |
+
"license": "cc-by-nc-4.0",
|
| 528 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because this voice is explicitly non-commercial and external-only.",
|
| 529 |
+
},
|
| 530 |
+
"IbrahimSalah/Arabic-F5-TTS-v2": {
|
| 531 |
+
"license": "fair-noncommercial-research-license",
|
| 532 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because it is non-commercial and requires diacritized Arabic.",
|
| 533 |
+
},
|
| 534 |
+
"Supertone/supertonic-3": {
|
| 535 |
+
"license": "openrail",
|
| 536 |
+
"reason": "Hugging Face model page was verified in research; keep page-only so the optional CPU voice benchmark remains tracked when live metadata fetches are blocked.",
|
| 537 |
+
},
|
| 538 |
+
"bilalRHCH/Arabic-text-to-speech": {
|
| 539 |
+
"license": "apache-2.0",
|
| 540 |
+
"reason": "Hugging Face model page was verified in research; keep page-only because this Arabic-focused OmniVoice package is benchmark-only and raw metadata fetches can fail locally.",
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
WATCHLIST_POLICY_PATH = ROOT_DIR / "docs" / "research-watchlist.md"
|
| 545 |
+
WATCHLIST_ALLOWED_DEFAULTS = {
|
| 546 |
+
"SILMA TTS",
|
| 547 |
+
}
|
| 548 |
+
WATCHLIST_ALLOWED_WIRED_OPTIONAL = {
|
| 549 |
+
"QARI-OCR 0.4",
|
| 550 |
+
"PaddleOCR-VL-1.6",
|
| 551 |
+
"KATIB 0.8B",
|
| 552 |
+
"Arabic-GLM-OCR-v2",
|
| 553 |
+
"Arabic-Qwen3.5-OCR-v4",
|
| 554 |
+
"Ketaba-OCR LoRA",
|
| 555 |
+
"Tawkeed OCR",
|
| 556 |
+
"Baseer OCR V1.0",
|
| 557 |
+
"Habibi-TTS MSA",
|
| 558 |
+
"Supertonic 3",
|
| 559 |
+
}
|
| 560 |
+
WATCHLIST_BENCHMARK_ONLY_REASONS = {
|
| 561 |
+
"DeepSeek-OCR-2": "external general OCR benchmark",
|
| 562 |
+
"aNS Qwen3-VL Arabic OCR v3": "fresh Qwen3-VL Arabic OCR benchmark with sparse production evidence",
|
| 563 |
+
"Waraqon v3 Arabic OCR HTML Qari": "external Qari-family structured HTML OCR benchmark",
|
| 564 |
+
"DeepSeek Arabic OCR v6": "external Arabic OCR benchmark",
|
| 565 |
+
"Loay Arabic-OCR-DeepSeek-OCR-2": "external Arabic DeepSeek-OCR-2 layout benchmark",
|
| 566 |
+
"Arabic-English handwritten OCR v3": "external handwriting/manuscript benchmark",
|
| 567 |
+
"Arabic handwritten OCR 4-bit Qwen2.5-VL": "external quantized handwriting/manuscript benchmark",
|
| 568 |
+
"NAKBA Arabic manuscript line OCR baseline": "line-level manuscript OCR benchmark with license confirmation",
|
| 569 |
+
"HAFITH": "historical Arabic manuscript line OCR benchmark",
|
| 570 |
+
"Glimpse RTL OCR": "Arabic/Persian RTL text-line OCR benchmark",
|
| 571 |
+
"Arabic OCR Qwen2.5-VL GGUF": "external GGUF Arabic OCR benchmark with license confirmation",
|
| 572 |
+
"Qwen3-VL Persian/Arabic line OCR": "line-level OCR benchmark",
|
| 573 |
+
"Loay Arabic-OCR-Qwen2.5-VL-7B": "large external Arabic OCR benchmark",
|
| 574 |
+
"DIMI Arabic OCR v2": "large external Arabic OCR benchmark",
|
| 575 |
+
"AtlasOCR": "license/content-specific OCR watchlist",
|
| 576 |
+
"NuExtract3": "external multilingual document OCR benchmark",
|
| 577 |
+
"Qianfan-OCR": "large external multilingual document OCR benchmark",
|
| 578 |
+
"Chandra OCR 2": "modified OpenRAIL structured-document OCR benchmark",
|
| 579 |
+
"dots.ocr": "external multilingual document-layout OCR benchmark",
|
| 580 |
+
"olmOCR Arabic LoRA v2": "full-page Arabic manuscript OCR benchmark with base-license/runtime confirmation",
|
| 581 |
+
"Arabic Large Nougat": "GPL Arabic book OCR-to-Markdown benchmark",
|
| 582 |
+
"DocTR Arabic FAST/PARSEQ": "classic Arabic OCR benchmark with recognition license confirmation",
|
| 583 |
+
"Kraken/eScriptorium Arabic script": "historical Arabic-script OCR benchmark with model-license confirmation",
|
| 584 |
+
"Kairawan/Qalamus manuscript OCR": "service-only Arabic manuscript OCR benchmark signal",
|
| 585 |
+
"GLM-OCR Arabic/French documents": "external Arabic/French document OCR benchmark",
|
| 586 |
+
"mimoha Arabic OCR": "sparse-card Arabic OCR watchlist",
|
| 587 |
+
"oi-OCR": "external document parser benchmark",
|
| 588 |
+
"Falcon-OCR": "external OCR benchmark",
|
| 589 |
+
"Raqim post-OCR correction": "correction-risk OCR caution",
|
| 590 |
+
"Arabic Legal Documents OCR 1.0": "Gemma-licensed domain-specific OCR caution",
|
| 591 |
+
"Mishkala Tashkeel": "pronunciation preprocessor benchmark",
|
| 592 |
+
"Tashkeel-350M": "larger pronunciation preprocessor benchmark",
|
| 593 |
+
"Mushkil": "AraT5V2 pronunciation preprocessor benchmark",
|
| 594 |
+
"Thaka KSAA-2026 speech diacritization": "research signal only",
|
| 595 |
+
"3arab-TTS 500M": "new Arabic voice benchmark",
|
| 596 |
+
"KaniTTS Arabic": "metadata/license uncertainty",
|
| 597 |
+
"Emirati VITS Male": "dialect voice benchmark",
|
| 598 |
+
"VoxCPM2": "large strong-worker voice benchmark",
|
| 599 |
+
"Voxtral TTS": "non-commercial license",
|
| 600 |
+
"OmniVoice": "external multilingual voice benchmark",
|
| 601 |
+
"OmniVoice Arabic LoRA": "external Arabic adapter benchmark",
|
| 602 |
+
"Arabic-text-to-speech OmniVoice": "external Arabic-focused OmniVoice benchmark",
|
| 603 |
+
"Lahgtna OmniVoice v2": "dialect and license-uncertain voice benchmark",
|
| 604 |
+
"TADA multilingual TTS": "Llama-licensed strong-worker voice benchmark",
|
| 605 |
+
"Lahgtna Chatterbox": "dialect voice benchmark",
|
| 606 |
+
"NAMAA-Saudi-TTS": "Saudi dialect voice benchmark",
|
| 607 |
+
"NAMAA-Egyptian-TTS": "Egyptian dialect voice benchmark",
|
| 608 |
+
"Saudi Chatterbox fine-tune": "Saudi dialect voice benchmark",
|
| 609 |
+
"Saudi TTS": "Saudi dialect voice benchmark",
|
| 610 |
+
"Egyptian Arabic Chatterbox": "Egyptian dialect voice benchmark",
|
| 611 |
+
"NileTTS-XTTS": "Egyptian dialect voice benchmark",
|
| 612 |
+
"Arabic XTTS-v2 Egyptian fine-tune": "CPML/base-license dialect XTTS benchmark",
|
| 613 |
+
"Chatterbox-Multilingual": "external multilingual voice benchmark",
|
| 614 |
+
"Chatterbox Arabic fine-tune": "MSA-focused Chatterbox Arabic adapter benchmark",
|
| 615 |
+
"Chatterbox-Multilingual ONNX": "external CPU/ONNX multilingual voice benchmark",
|
| 616 |
+
"tts-arabic-onnx": "license-unclear compact Arabic ONNX voice benchmark",
|
| 617 |
+
"Spark-TTS Arabic": "external Arabic voice-cloning benchmark",
|
| 618 |
+
"Sofelia-TTS": "Palestinian dialect voice benchmark",
|
| 619 |
+
"Arabic-F5-TTS-v2": "non-commercial voice caution",
|
| 620 |
+
"MOSS-TTS-Nano": "external CPU-friendly multilingual benchmark",
|
| 621 |
+
"Qwen3-TTS": "not Arabic-ready from official released model cards",
|
| 622 |
+
"Saudi Arabic Qwen3-TTS": "Saudi/Gulf dialect voice benchmark",
|
| 623 |
+
"Emirati Qwen3.5-TTS": "Emirati/Gulf dialect voice benchmark",
|
| 624 |
+
}
|
| 625 |
+
WATCHLIST_BENCHMARK_STATUS_MARKERS = (
|
| 626 |
+
"not wired",
|
| 627 |
+
"benchmark",
|
| 628 |
+
"research signal",
|
| 629 |
+
"not default",
|
| 630 |
+
"not arabic-ready",
|
| 631 |
+
"correction can alter",
|
| 632 |
+
)
|
| 633 |
+
WATCHLIST_PROHIBITED_DEFAULT_MARKERS = (
|
| 634 |
+
"wired default",
|
| 635 |
+
"default local",
|
| 636 |
+
"production default",
|
| 637 |
+
)
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
@dataclass
|
| 641 |
+
class SourceCheck:
|
| 642 |
+
name: str
|
| 643 |
+
ok: bool
|
| 644 |
+
detail: str
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def format_report_date(value: date | None = None) -> str:
|
| 648 |
+
value = value or date.today()
|
| 649 |
+
return value.strftime("%B %d, %Y").replace(" 0", " ")
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def parse_report_date(value: str) -> date | None:
|
| 653 |
+
try:
|
| 654 |
+
return datetime.strptime(value.strip().rstrip("."), "%B %d, %Y").date()
|
| 655 |
+
except ValueError:
|
| 656 |
+
return None
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def metadata_refresh_date(text: str) -> date | None:
|
| 660 |
+
match = re.search(r"^Last refreshed:\s*(.+)$", text, re.MULTILINE)
|
| 661 |
+
if not match:
|
| 662 |
+
return None
|
| 663 |
+
return parse_report_date(match.group(1))
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def metadata_freshness_check(text: str, max_age_days: int = MAX_METADATA_AGE_DAYS) -> SourceCheck:
|
| 667 |
+
refreshed = metadata_refresh_date(text)
|
| 668 |
+
if refreshed is None:
|
| 669 |
+
return SourceCheck("metadata freshness", False, "missing or invalid Last refreshed date")
|
| 670 |
+
today = date.today()
|
| 671 |
+
age_days = (today - refreshed).days
|
| 672 |
+
if age_days < 0:
|
| 673 |
+
return SourceCheck("metadata freshness", False, f"future refresh date {refreshed.isoformat()}")
|
| 674 |
+
return SourceCheck(
|
| 675 |
+
"metadata freshness",
|
| 676 |
+
age_days <= max_age_days,
|
| 677 |
+
f"refreshed={refreshed.isoformat()} ageDays={age_days} maxAgeDays={max_age_days}",
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def parse_detail_fields(detail: str) -> dict[str, str]:
|
| 682 |
+
fields: dict[str, str] = {}
|
| 683 |
+
for part in detail.split():
|
| 684 |
+
if "=" not in part:
|
| 685 |
+
continue
|
| 686 |
+
key, value = part.split("=", 1)
|
| 687 |
+
fields[key] = value
|
| 688 |
+
return fields
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def extract_urls(paths: list[Path]) -> list[str]:
|
| 692 |
+
urls: list[str] = []
|
| 693 |
+
seen: set[str] = set()
|
| 694 |
+
for path in paths:
|
| 695 |
+
if not path.exists():
|
| 696 |
+
continue
|
| 697 |
+
for match in URL_RE.findall(path.read_text(encoding="utf-8", errors="replace")):
|
| 698 |
+
url = match.rstrip(".,")
|
| 699 |
+
if url not in seen:
|
| 700 |
+
seen.add(url)
|
| 701 |
+
urls.append(url)
|
| 702 |
+
return urls
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def check_required_markers(path: Path = ROOT_DIR / "docs" / "source-evidence.md") -> list[SourceCheck]:
|
| 706 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 707 |
+
checks: list[SourceCheck] = []
|
| 708 |
+
for marker in REQUIRED_SOURCE_MARKERS:
|
| 709 |
+
checks.append(SourceCheck(f"source marker: {marker}", marker in text, "present" if marker in text else "missing"))
|
| 710 |
+
return checks
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def check_metadata_snapshot(path: Path = ROOT_DIR / "docs" / "huggingface-model-metadata.md") -> list[SourceCheck]:
|
| 714 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 715 |
+
checks = [SourceCheck("metadata snapshot exists", bool(text), str(path) if text else "missing")]
|
| 716 |
+
checks.append(metadata_freshness_check(text))
|
| 717 |
+
for marker in REQUIRED_METADATA_MARKERS:
|
| 718 |
+
checks.append(SourceCheck(f"metadata marker: {marker}", marker in text, "present" if marker in text else "missing"))
|
| 719 |
+
return checks
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def check_recommendation_report(path: Path = ROOT_DIR / "docs" / "recommended-free-stack.md") -> list[SourceCheck]:
|
| 723 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 724 |
+
checks = [SourceCheck("recommendation report exists", bool(text), str(path) if text else "missing")]
|
| 725 |
+
for marker in REQUIRED_RECOMMENDATION_MARKERS:
|
| 726 |
+
checks.append(
|
| 727 |
+
SourceCheck(
|
| 728 |
+
f"recommendation marker: {marker}",
|
| 729 |
+
marker in text,
|
| 730 |
+
"present" if marker in text else "missing",
|
| 731 |
+
)
|
| 732 |
+
)
|
| 733 |
+
return checks
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
def check_decision_card(
|
| 737 |
+
markdown_path: Path = ROOT_DIR / "docs" / "recommended-decision-card.md",
|
| 738 |
+
json_path: Path = ROOT_DIR / "docs" / "recommended-decision-card.json",
|
| 739 |
+
) -> list[SourceCheck]:
|
| 740 |
+
markdown = markdown_path.read_text(encoding="utf-8", errors="replace") if markdown_path.exists() else ""
|
| 741 |
+
json_text = json_path.read_text(encoding="utf-8", errors="replace") if json_path.exists() else ""
|
| 742 |
+
combined = f"{markdown}\n{json_text}"
|
| 743 |
+
checks = [
|
| 744 |
+
SourceCheck("decision card markdown exists", bool(markdown), str(markdown_path) if markdown else "missing"),
|
| 745 |
+
SourceCheck("decision card json exists", bool(json_text), str(json_path) if json_text else "missing"),
|
| 746 |
+
]
|
| 747 |
+
for marker in REQUIRED_DECISION_CARD_MARKERS:
|
| 748 |
+
checks.append(
|
| 749 |
+
SourceCheck(
|
| 750 |
+
f"decision card marker: {marker}",
|
| 751 |
+
marker in combined,
|
| 752 |
+
"present" if marker in combined else "missing",
|
| 753 |
+
)
|
| 754 |
+
)
|
| 755 |
+
return checks
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def check_watchlist_command_markers(path: Path = WATCHLIST_POLICY_PATH) -> list[SourceCheck]:
|
| 759 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 760 |
+
checks = [SourceCheck("watchlist command section exists", "## Benchmark Steps" in text, str(path) if text else "missing")]
|
| 761 |
+
for marker in REQUIRED_WATCHLIST_COMMAND_MARKERS:
|
| 762 |
+
checks.append(
|
| 763 |
+
SourceCheck(
|
| 764 |
+
f"watchlist command marker: {marker}",
|
| 765 |
+
marker in text,
|
| 766 |
+
"present" if marker in text else "missing",
|
| 767 |
+
)
|
| 768 |
+
)
|
| 769 |
+
return checks
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def check_workflow_doc_markers(paths: list[Path] | None = None) -> list[SourceCheck]:
|
| 773 |
+
docs = paths or WORKFLOW_DOC_PATHS
|
| 774 |
+
checks: list[SourceCheck] = []
|
| 775 |
+
for path in docs:
|
| 776 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 777 |
+
checks.append(SourceCheck(f"workflow doc exists: {path.name}", bool(text), str(path) if text else "missing"))
|
| 778 |
+
for marker in REQUIRED_WORKFLOW_MARKERS:
|
| 779 |
+
checks.append(
|
| 780 |
+
SourceCheck(
|
| 781 |
+
f"workflow doc marker: {path.name}: {marker}",
|
| 782 |
+
marker in text,
|
| 783 |
+
"present" if marker in text else "missing",
|
| 784 |
+
)
|
| 785 |
+
)
|
| 786 |
+
return checks
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def parse_markdown_table_rows(text: str) -> list[dict[str, str]]:
|
| 790 |
+
headers: list[str] = []
|
| 791 |
+
rows: list[dict[str, str]] = []
|
| 792 |
+
for raw_line in text.splitlines():
|
| 793 |
+
line = raw_line.strip()
|
| 794 |
+
if not line.startswith("|") or not line.endswith("|"):
|
| 795 |
+
continue
|
| 796 |
+
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
| 797 |
+
if not cells:
|
| 798 |
+
continue
|
| 799 |
+
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
|
| 800 |
+
continue
|
| 801 |
+
if not headers:
|
| 802 |
+
headers = [cell.lower() for cell in cells]
|
| 803 |
+
continue
|
| 804 |
+
if len(cells) != len(headers):
|
| 805 |
+
continue
|
| 806 |
+
rows.append(dict(zip(headers, cells)))
|
| 807 |
+
return rows
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def status_is_benchmark_only(status: str) -> bool:
|
| 811 |
+
normalized = status.lower()
|
| 812 |
+
prohibited = any(marker in normalized for marker in WATCHLIST_PROHIBITED_DEFAULT_MARKERS)
|
| 813 |
+
allowed = any(marker in normalized for marker in WATCHLIST_BENCHMARK_STATUS_MARKERS)
|
| 814 |
+
return allowed and not prohibited
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def check_watchlist_policy(path: Path = WATCHLIST_POLICY_PATH) -> list[SourceCheck]:
|
| 818 |
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
| 819 |
+
rows = parse_markdown_table_rows(text)
|
| 820 |
+
checks = [SourceCheck("research watchlist table parsed", bool(rows), f"{len(rows)} rows")]
|
| 821 |
+
by_candidate = {row.get("candidate", ""): row for row in rows}
|
| 822 |
+
|
| 823 |
+
for candidate, reason in WATCHLIST_BENCHMARK_ONLY_REASONS.items():
|
| 824 |
+
row = by_candidate.get(candidate)
|
| 825 |
+
if row is None:
|
| 826 |
+
checks.append(SourceCheck(f"watchlist policy: {candidate}", False, f"missing; reason={reason}"))
|
| 827 |
+
continue
|
| 828 |
+
status = row.get("status", "")
|
| 829 |
+
checks.append(
|
| 830 |
+
SourceCheck(
|
| 831 |
+
f"watchlist policy: {candidate}",
|
| 832 |
+
status_is_benchmark_only(status),
|
| 833 |
+
f"reason={reason} status={status or '-'}",
|
| 834 |
+
)
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
+
for candidate in WATCHLIST_ALLOWED_DEFAULTS:
|
| 838 |
+
row = by_candidate.get(candidate)
|
| 839 |
+
status = row.get("status", "") if row else ""
|
| 840 |
+
checks.append(
|
| 841 |
+
SourceCheck(
|
| 842 |
+
f"watchlist default allowed: {candidate}",
|
| 843 |
+
row is not None and "wired default" in status.lower(),
|
| 844 |
+
f"status={status or '-'}",
|
| 845 |
+
)
|
| 846 |
+
)
|
| 847 |
+
|
| 848 |
+
for candidate in WATCHLIST_ALLOWED_WIRED_OPTIONAL:
|
| 849 |
+
row = by_candidate.get(candidate)
|
| 850 |
+
status = row.get("status", "") if row else ""
|
| 851 |
+
normalized = status.lower()
|
| 852 |
+
ok = row is not None and ("wired optional" in normalized or status_is_benchmark_only(status))
|
| 853 |
+
checks.append(
|
| 854 |
+
SourceCheck(
|
| 855 |
+
f"watchlist optional/default policy: {candidate}",
|
| 856 |
+
ok,
|
| 857 |
+
f"status={status or '-'}",
|
| 858 |
+
)
|
| 859 |
+
)
|
| 860 |
+
|
| 861 |
+
return checks
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
def check_url(url: str, timeout: float = 12.0) -> SourceCheck:
|
| 865 |
+
request = Request(url, headers={"User-Agent": "ArabicAudioReaderSourceCheck/1.0"})
|
| 866 |
+
try:
|
| 867 |
+
with urlopen(request, timeout=timeout) as response:
|
| 868 |
+
status = getattr(response, "status", 200)
|
| 869 |
+
ok = 200 <= int(status) < 400
|
| 870 |
+
return SourceCheck(url, ok, f"HTTP {status}")
|
| 871 |
+
except HTTPError as exc:
|
| 872 |
+
return SourceCheck(url, False, f"HTTP {exc.code}")
|
| 873 |
+
except URLError as exc:
|
| 874 |
+
return SourceCheck(url, False, f"URL error: {exc.reason}")
|
| 875 |
+
except TimeoutError:
|
| 876 |
+
return SourceCheck(url, False, "timeout")
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
def huggingface_model_id(url: str) -> str | None:
|
| 880 |
+
prefix = "https://huggingface.co/"
|
| 881 |
+
if not url.startswith(prefix):
|
| 882 |
+
return None
|
| 883 |
+
rest = url[len(prefix) :].strip("/")
|
| 884 |
+
parts = rest.split("/")
|
| 885 |
+
if len(parts) < 2 or parts[0] in {"docs", "datasets", "spaces"}:
|
| 886 |
+
return None
|
| 887 |
+
return "/".join(parts[:2])
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def model_license(metadata: dict[str, object]) -> str:
|
| 891 |
+
card_data = metadata.get("cardData")
|
| 892 |
+
if isinstance(card_data, dict):
|
| 893 |
+
license_value = card_data.get("license")
|
| 894 |
+
if isinstance(license_value, str) and license_value.strip():
|
| 895 |
+
return license_value.strip().lower()
|
| 896 |
+
tags = metadata.get("tags")
|
| 897 |
+
if isinstance(tags, list):
|
| 898 |
+
for tag in tags:
|
| 899 |
+
if isinstance(tag, str) and tag.startswith("license:"):
|
| 900 |
+
return tag.split(":", 1)[1].strip().lower()
|
| 901 |
+
return ""
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
def fetch_huggingface_model(model_id: str, timeout: float = 12.0) -> dict[str, object]:
|
| 905 |
+
url = f"https://huggingface.co/api/models/{model_id}"
|
| 906 |
+
headers = {"User-Agent": "ArabicAudioReaderSourceCheck/1.0"}
|
| 907 |
+
token = os.getenv("HF_API_TOKEN") or os.getenv("HUGGINGFACE_API_TOKEN")
|
| 908 |
+
if token:
|
| 909 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 910 |
+
request = Request(url, headers=headers)
|
| 911 |
+
with urlopen(request, timeout=timeout) as response:
|
| 912 |
+
raw = response.read().decode("utf-8", errors="replace")
|
| 913 |
+
data = json.loads(raw)
|
| 914 |
+
return data if isinstance(data, dict) else {}
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def fetch_huggingface_model_page_metadata(model_id: str, timeout: float = 12.0) -> dict[str, object]:
|
| 918 |
+
url = f"https://huggingface.co/{model_id}"
|
| 919 |
+
request = Request(url, headers={"User-Agent": "ArabicAudioReaderSourceCheck/1.0"})
|
| 920 |
+
with urlopen(request, timeout=timeout) as response:
|
| 921 |
+
html = response.read().decode("utf-8", errors="replace")
|
| 922 |
+
license_match = re.search(r"License:\s*</span>\s*<span[^>]*>\s*([^<\s]+)", html, re.IGNORECASE)
|
| 923 |
+
if license_match is None:
|
| 924 |
+
license_match = re.search(r"License:\s*([A-Za-z0-9_.+-]+)", html, re.IGNORECASE)
|
| 925 |
+
license_value = license_match.group(1).strip().lower() if license_match else ""
|
| 926 |
+
return {
|
| 927 |
+
"id": model_id,
|
| 928 |
+
"private": False,
|
| 929 |
+
"disabled": False,
|
| 930 |
+
"lastModified": "page-fallback",
|
| 931 |
+
"cardData": {"license": license_value} if license_value else {},
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
def collect_huggingface_metadata_checks(timeout: float = 12.0) -> list[SourceCheck]:
|
| 936 |
+
model_ids: list[str] = []
|
| 937 |
+
for url in KEY_SOURCE_URLS.values():
|
| 938 |
+
model_id = huggingface_model_id(url)
|
| 939 |
+
if model_id and model_id not in model_ids:
|
| 940 |
+
model_ids.append(model_id)
|
| 941 |
+
|
| 942 |
+
checks: list[SourceCheck] = []
|
| 943 |
+
for model_id in model_ids:
|
| 944 |
+
if model_id in HF_PAGE_ONLY_METADATA:
|
| 945 |
+
fallback = HF_PAGE_ONLY_METADATA[model_id]
|
| 946 |
+
license_value = str(fallback.get("license") or "")
|
| 947 |
+
checks.append(
|
| 948 |
+
SourceCheck(
|
| 949 |
+
f"huggingface metadata: {model_id}",
|
| 950 |
+
True,
|
| 951 |
+
f"id={model_id} license={license_value or '-'} lastModified=page-only",
|
| 952 |
+
)
|
| 953 |
+
)
|
| 954 |
+
expected_license = HF_EXPECTED_LICENSES.get(model_id)
|
| 955 |
+
if expected_license:
|
| 956 |
+
checks.append(
|
| 957 |
+
SourceCheck(
|
| 958 |
+
f"huggingface license: {model_id}",
|
| 959 |
+
license_value == expected_license,
|
| 960 |
+
f"expected={expected_license} actual={license_value or '-'}",
|
| 961 |
+
)
|
| 962 |
+
)
|
| 963 |
+
continue
|
| 964 |
+
try:
|
| 965 |
+
metadata = fetch_huggingface_model(model_id, timeout=timeout)
|
| 966 |
+
except HTTPError as exc:
|
| 967 |
+
if exc.code in {401, 404}:
|
| 968 |
+
try:
|
| 969 |
+
metadata = fetch_huggingface_model_page_metadata(model_id, timeout=timeout)
|
| 970 |
+
except Exception:
|
| 971 |
+
checks.append(SourceCheck(f"huggingface metadata: {model_id}", False, f"HTTP {exc.code}"))
|
| 972 |
+
continue
|
| 973 |
+
else:
|
| 974 |
+
checks.append(SourceCheck(f"huggingface metadata: {model_id}", False, f"HTTP {exc.code}"))
|
| 975 |
+
continue
|
| 976 |
+
except (URLError, TimeoutError) as exc:
|
| 977 |
+
checks.append(SourceCheck(f"huggingface metadata: {model_id}", False, str(exc)))
|
| 978 |
+
continue
|
| 979 |
+
except json.JSONDecodeError:
|
| 980 |
+
checks.append(SourceCheck(f"huggingface metadata: {model_id}", False, "invalid JSON"))
|
| 981 |
+
continue
|
| 982 |
+
|
| 983 |
+
reported_id = str(metadata.get("id") or metadata.get("modelId") or "")
|
| 984 |
+
private = bool(metadata.get("private"))
|
| 985 |
+
disabled = bool(metadata.get("disabled"))
|
| 986 |
+
last_modified = str(metadata.get("lastModified") or metadata.get("createdAt") or "unknown")
|
| 987 |
+
license_value = model_license(metadata)
|
| 988 |
+
checks.append(
|
| 989 |
+
SourceCheck(
|
| 990 |
+
f"huggingface metadata: {model_id}",
|
| 991 |
+
reported_id == model_id and not private and not disabled,
|
| 992 |
+
f"id={reported_id or '-'} license={license_value or '-'} lastModified={last_modified}",
|
| 993 |
+
)
|
| 994 |
+
)
|
| 995 |
+
|
| 996 |
+
expected_license = HF_EXPECTED_LICENSES.get(model_id)
|
| 997 |
+
if expected_license:
|
| 998 |
+
checks.append(
|
| 999 |
+
SourceCheck(
|
| 1000 |
+
f"huggingface license: {model_id}",
|
| 1001 |
+
license_value == expected_license,
|
| 1002 |
+
f"expected={expected_license} actual={license_value or '-'}",
|
| 1003 |
+
)
|
| 1004 |
+
)
|
| 1005 |
+
return checks
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
def collect_checks(
|
| 1009 |
+
paths: list[Path] | None = None,
|
| 1010 |
+
check_links: bool = False,
|
| 1011 |
+
timeout: float = 12.0,
|
| 1012 |
+
metadata_path: Path | None = None,
|
| 1013 |
+
) -> list[SourceCheck]:
|
| 1014 |
+
docs = paths or DEFAULT_DOCS
|
| 1015 |
+
checks = check_required_markers()
|
| 1016 |
+
checks.extend(check_metadata_snapshot(metadata_path or ROOT_DIR / "docs" / "huggingface-model-metadata.md"))
|
| 1017 |
+
checks.extend(check_recommendation_report())
|
| 1018 |
+
checks.extend(check_decision_card())
|
| 1019 |
+
checks.extend(check_watchlist_policy())
|
| 1020 |
+
checks.extend(check_watchlist_command_markers())
|
| 1021 |
+
checks.extend(check_workflow_doc_markers())
|
| 1022 |
+
urls = extract_urls(docs)
|
| 1023 |
+
checks.append(SourceCheck("source urls found", bool(urls), f"{len(urls)} unique URLs"))
|
| 1024 |
+
if check_links:
|
| 1025 |
+
checks.extend(check_url(url, timeout=timeout) for url in urls)
|
| 1026 |
+
return checks
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
def collect_command_checks(
|
| 1030 |
+
*,
|
| 1031 |
+
check_links: bool = False,
|
| 1032 |
+
check_key_links: bool = False,
|
| 1033 |
+
check_representative_links: bool = False,
|
| 1034 |
+
check_hf_metadata: bool = False,
|
| 1035 |
+
write_hf_metadata_report: Path | None = None,
|
| 1036 |
+
timeout: float = 12.0,
|
| 1037 |
+
) -> list[SourceCheck]:
|
| 1038 |
+
hf_checks: list[SourceCheck] | None = None
|
| 1039 |
+
if check_hf_metadata or write_hf_metadata_report:
|
| 1040 |
+
hf_checks = collect_huggingface_metadata_checks(timeout=timeout)
|
| 1041 |
+
if write_hf_metadata_report:
|
| 1042 |
+
write_huggingface_metadata_report(write_hf_metadata_report, hf_checks)
|
| 1043 |
+
|
| 1044 |
+
checks = collect_checks(
|
| 1045 |
+
check_links=check_links,
|
| 1046 |
+
timeout=timeout,
|
| 1047 |
+
metadata_path=write_hf_metadata_report,
|
| 1048 |
+
)
|
| 1049 |
+
checks.extend(collect_key_source_checks(check_links=check_key_links, timeout=timeout))
|
| 1050 |
+
if check_representative_links:
|
| 1051 |
+
checks.extend(collect_representative_link_checks(timeout=timeout))
|
| 1052 |
+
if hf_checks is not None:
|
| 1053 |
+
checks.extend(hf_checks)
|
| 1054 |
+
return checks
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
def representative_source_urls(urls: list[str]) -> list[str]:
|
| 1058 |
+
preferred_hosts = ["huggingface.co", "github.com", "paddleocr.ai", "arxiv.org", "vercel.com"]
|
| 1059 |
+
selected: list[str] = []
|
| 1060 |
+
for host in preferred_hosts:
|
| 1061 |
+
match = next((url for url in urls if host in url), None)
|
| 1062 |
+
if match and match not in selected:
|
| 1063 |
+
selected.append(match)
|
| 1064 |
+
return selected
|
| 1065 |
+
|
| 1066 |
+
|
| 1067 |
+
def collect_representative_link_checks(timeout: float = 8.0) -> list[SourceCheck]:
|
| 1068 |
+
urls = representative_source_urls(extract_urls(DEFAULT_DOCS))
|
| 1069 |
+
checks = [SourceCheck("representative source urls selected", bool(urls), f"{len(urls)} URLs")]
|
| 1070 |
+
checks.extend(check_url(url, timeout=timeout) for url in urls)
|
| 1071 |
+
return checks
|
| 1072 |
+
|
| 1073 |
+
|
| 1074 |
+
def collect_key_source_checks(paths: list[Path] | None = None, timeout: float = 8.0, check_links: bool = False) -> list[SourceCheck]:
|
| 1075 |
+
docs = paths or DEFAULT_DOCS
|
| 1076 |
+
urls = set(extract_urls(docs))
|
| 1077 |
+
checks: list[SourceCheck] = []
|
| 1078 |
+
for name, url in KEY_SOURCE_URLS.items():
|
| 1079 |
+
present = url in urls
|
| 1080 |
+
checks.append(SourceCheck(f"key source listed: {name}", present, url if present else f"missing {url}"))
|
| 1081 |
+
if present and check_links:
|
| 1082 |
+
link_check = check_url(url, timeout=timeout)
|
| 1083 |
+
checks.append(SourceCheck(f"key source reachable: {name}", link_check.ok, link_check.detail))
|
| 1084 |
+
return checks
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
def summarize(checks: list[SourceCheck]) -> dict[str, object]:
|
| 1088 |
+
passed = sum(1 for check in checks if check.ok)
|
| 1089 |
+
failed = len(checks) - passed
|
| 1090 |
+
return {
|
| 1091 |
+
"ready": failed == 0,
|
| 1092 |
+
"counts": {"PASS": passed, "FAIL": failed},
|
| 1093 |
+
"checks": [asdict(check) for check in checks],
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
def build_huggingface_metadata_report(checks: list[SourceCheck], refreshed_at: date | None = None) -> str:
|
| 1098 |
+
model_rows: dict[str, dict[str, str]] = {}
|
| 1099 |
+
for check in checks:
|
| 1100 |
+
if check.name.startswith("huggingface metadata: "):
|
| 1101 |
+
model_id = check.name.removeprefix("huggingface metadata: ")
|
| 1102 |
+
fields = parse_detail_fields(check.detail)
|
| 1103 |
+
model_rows[model_id] = {
|
| 1104 |
+
"model": model_id,
|
| 1105 |
+
"status": "PASS" if check.ok else "FAIL",
|
| 1106 |
+
"reportedId": fields.get("id", "-"),
|
| 1107 |
+
"license": fields.get("license", "-"),
|
| 1108 |
+
"lastModified": fields.get("lastModified", "-"),
|
| 1109 |
+
"licenseCheck": "-",
|
| 1110 |
+
}
|
| 1111 |
+
elif check.name.startswith("huggingface license: "):
|
| 1112 |
+
model_id = check.name.removeprefix("huggingface license: ")
|
| 1113 |
+
row = model_rows.setdefault(
|
| 1114 |
+
model_id,
|
| 1115 |
+
{
|
| 1116 |
+
"model": model_id,
|
| 1117 |
+
"status": "-",
|
| 1118 |
+
"reportedId": "-",
|
| 1119 |
+
"license": "-",
|
| 1120 |
+
"lastModified": "-",
|
| 1121 |
+
"licenseCheck": "-",
|
| 1122 |
+
},
|
| 1123 |
+
)
|
| 1124 |
+
row["licenseCheck"] = "PASS" if check.ok else f"FAIL ({check.detail})"
|
| 1125 |
+
|
| 1126 |
+
for model_id, fallback in HF_PAGE_ONLY_METADATA.items():
|
| 1127 |
+
existing = model_rows.get(model_id)
|
| 1128 |
+
if existing and existing.get("status") != "FAIL":
|
| 1129 |
+
continue
|
| 1130 |
+
license_value = str(fallback.get("license") or "-")
|
| 1131 |
+
expected_license = HF_EXPECTED_LICENSES.get(model_id)
|
| 1132 |
+
model_rows[model_id] = {
|
| 1133 |
+
"model": model_id,
|
| 1134 |
+
"status": "PASS",
|
| 1135 |
+
"reportedId": model_id,
|
| 1136 |
+
"license": license_value,
|
| 1137 |
+
"lastModified": "page-only",
|
| 1138 |
+
"licenseCheck": "PASS" if expected_license == license_value else "-",
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
lines = [
|
| 1142 |
+
"# Hugging Face Model Metadata",
|
| 1143 |
+
"",
|
| 1144 |
+
f"Last refreshed: {format_report_date(refreshed_at)}.",
|
| 1145 |
+
"",
|
| 1146 |
+
"Generated by `scripts/check_research_sources.py --check-hf-metadata`.",
|
| 1147 |
+
"",
|
| 1148 |
+
"Rows marked `page-only` use verified public model-page evidence when live Hugging Face API/socket metadata is unavailable in the local environment.",
|
| 1149 |
+
"",
|
| 1150 |
+
"| Model | Status | Reported ID | License | License Check | Last Modified |",
|
| 1151 |
+
"| --- | --- | --- | --- | --- | --- |",
|
| 1152 |
+
]
|
| 1153 |
+
for row in sorted(model_rows.values(), key=lambda item: item["model"].lower()):
|
| 1154 |
+
lines.append(
|
| 1155 |
+
f"| {row['model']} | {row['status']} | {row['reportedId']} | {row['license']} | "
|
| 1156 |
+
f"{row['licenseCheck']} | {row['lastModified']} |"
|
| 1157 |
+
)
|
| 1158 |
+
return "\n".join(lines) + "\n"
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
def write_huggingface_metadata_report(path: Path, checks: list[SourceCheck]) -> None:
|
| 1162 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 1163 |
+
path.write_text(build_huggingface_metadata_report(checks), encoding="utf-8")
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
def main() -> None:
|
| 1167 |
+
parser = argparse.ArgumentParser(description="Check research source coverage for the Arabic audio reader.")
|
| 1168 |
+
parser.add_argument("--check-links", action="store_true", help="Fetch each source URL and require HTTP 2xx/3xx.")
|
| 1169 |
+
parser.add_argument(
|
| 1170 |
+
"--check-representative-links",
|
| 1171 |
+
action="store_true",
|
| 1172 |
+
help="Fetch one representative URL from the major source domains.",
|
| 1173 |
+
)
|
| 1174 |
+
parser.add_argument(
|
| 1175 |
+
"--check-key-links",
|
| 1176 |
+
action="store_true",
|
| 1177 |
+
help="Fetch the exact key OCR/TTS/hosting source URLs used by the recommendation.",
|
| 1178 |
+
)
|
| 1179 |
+
parser.add_argument(
|
| 1180 |
+
"--check-hf-metadata",
|
| 1181 |
+
action="store_true",
|
| 1182 |
+
help="Fetch Hugging Face model metadata for key source URLs and verify known licenses/private/disabled state.",
|
| 1183 |
+
)
|
| 1184 |
+
parser.add_argument(
|
| 1185 |
+
"--write-hf-metadata-report",
|
| 1186 |
+
type=Path,
|
| 1187 |
+
help="Write a Markdown table of Hugging Face model IDs, licenses, and last-modified dates.",
|
| 1188 |
+
)
|
| 1189 |
+
parser.add_argument("--timeout", type=float, default=12.0, help="Per-link timeout in seconds.")
|
| 1190 |
+
parser.add_argument("--json", action="store_true", help="Print JSON.")
|
| 1191 |
+
args = parser.parse_args()
|
| 1192 |
+
|
| 1193 |
+
checks = collect_command_checks(
|
| 1194 |
+
check_links=args.check_links,
|
| 1195 |
+
check_key_links=args.check_key_links,
|
| 1196 |
+
check_representative_links=args.check_representative_links,
|
| 1197 |
+
check_hf_metadata=args.check_hf_metadata,
|
| 1198 |
+
write_hf_metadata_report=args.write_hf_metadata_report,
|
| 1199 |
+
timeout=args.timeout,
|
| 1200 |
+
)
|
| 1201 |
+
summary = summarize(checks)
|
| 1202 |
+
if args.json:
|
| 1203 |
+
print(json.dumps(summary, indent=2))
|
| 1204 |
+
else:
|
| 1205 |
+
for check in checks:
|
| 1206 |
+
status = "PASS" if check.ok else "FAIL"
|
| 1207 |
+
print(f"{status:<4} {check.name} {check.detail}")
|
| 1208 |
+
if not summary["ready"]:
|
| 1209 |
+
raise SystemExit(1)
|
| 1210 |
+
|
| 1211 |
+
|
| 1212 |
+
if __name__ == "__main__":
|
| 1213 |
+
main()
|
scripts/check_test_environment.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
REQUIRED_MODULES = {
|
| 8 |
+
"pytest": "pytest",
|
| 9 |
+
"fastapi": "fastapi",
|
| 10 |
+
"httpx": "httpx",
|
| 11 |
+
"fitz": "PyMuPDF",
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def missing_modules() -> list[str]:
|
| 16 |
+
return [package for module, package in REQUIRED_MODULES.items() if importlib.util.find_spec(module) is None]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> int:
|
| 20 |
+
missing = missing_modules()
|
| 21 |
+
if not missing:
|
| 22 |
+
print("Test environment ready.")
|
| 23 |
+
return 0
|
| 24 |
+
print("Test environment is missing: " + ", ".join(missing))
|
| 25 |
+
print("Install project test dependencies with:")
|
| 26 |
+
print("python -m pip install -r requirements.txt")
|
| 27 |
+
return 1
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
raise SystemExit(main())
|
scripts/cleanup_outputs.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 9 |
+
if str(ROOT_DIR) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 11 |
+
|
| 12 |
+
from app import main
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main_cli() -> None:
|
| 16 |
+
parser = argparse.ArgumentParser(description="Delete old generated audio files and abandoned temp folders.")
|
| 17 |
+
parser.add_argument("--days", type=int, default=main.OUTPUT_RETENTION_DAYS, help="Delete files older than this many days.")
|
| 18 |
+
parser.add_argument("--max-files", type=int, default=main.OUTPUT_MAX_FILES, help="Keep only the newest N audio files.")
|
| 19 |
+
parser.add_argument("--output-dir", type=Path, default=main.OUTPUT_DIR, help="Directory containing generated audio files.")
|
| 20 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact summary.")
|
| 21 |
+
args = parser.parse_args()
|
| 22 |
+
|
| 23 |
+
result = main.cleanup_output_storage(args.output_dir, args.days, args.max_files)
|
| 24 |
+
if args.json:
|
| 25 |
+
print(json.dumps(result, indent=2))
|
| 26 |
+
else:
|
| 27 |
+
mb = result["bytes"] / 1024 / 1024
|
| 28 |
+
print(
|
| 29 |
+
f"Deleted {result['files']} audio file(s), {result['directories']} temp folder(s), "
|
| 30 |
+
f"freed {mb:.1f} MB."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main_cli()
|
scripts/configure_vercel_worker.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import subprocess
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Sequence
|
| 8 |
+
|
| 9 |
+
from scripts.verify_site import verify_site
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
DEFAULT_SITE_URL = "https://arabic-translator-mu.vercel.app"
|
| 13 |
+
DIRECT_CLOUD_ENV_KEYS = ["ENABLE_DIRECT_CLOUD_TTS", "HF_API_TOKEN", "HF_TTS_MODEL", "DEFAULT_VOICE_ID"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class CommandResult:
|
| 18 |
+
command: list[str]
|
| 19 |
+
returncode: int
|
| 20 |
+
stdout: str
|
| 21 |
+
stderr: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_url(value: str) -> str:
|
| 25 |
+
return value.rstrip("/")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def looks_like_hf_space(url: str) -> bool:
|
| 29 |
+
host = url.split("://", 1)[-1].split("/", 1)[0].lower()
|
| 30 |
+
return host == "hf.space" or host.endswith(".hf.space")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def validate_worker_url(worker_url: str) -> str:
|
| 34 |
+
worker_url = normalize_url(worker_url.strip())
|
| 35 |
+
if not worker_url.startswith("https://"):
|
| 36 |
+
raise ValueError("WORKER_BASE_URL must start with https://")
|
| 37 |
+
if "localhost" in worker_url or "127.0.0.1" in worker_url:
|
| 38 |
+
raise ValueError("WORKER_BASE_URL must be public, not localhost")
|
| 39 |
+
if ".vercel.app" in worker_url:
|
| 40 |
+
raise ValueError("WORKER_BASE_URL must point at the worker, not the Vercel site")
|
| 41 |
+
if not looks_like_hf_space(worker_url):
|
| 42 |
+
raise ValueError("WORKER_BASE_URL should be a Hugging Face Space URL ending in .hf.space")
|
| 43 |
+
return worker_url
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_command(command: Sequence[str], input_text: str | None = None, check: bool = True) -> CommandResult:
|
| 47 |
+
completed = subprocess.run(
|
| 48 |
+
list(command),
|
| 49 |
+
input=input_text,
|
| 50 |
+
text=True,
|
| 51 |
+
capture_output=True,
|
| 52 |
+
)
|
| 53 |
+
result = CommandResult(
|
| 54 |
+
command=list(command),
|
| 55 |
+
returncode=completed.returncode,
|
| 56 |
+
stdout=completed.stdout,
|
| 57 |
+
stderr=completed.stderr,
|
| 58 |
+
)
|
| 59 |
+
if check and completed.returncode != 0:
|
| 60 |
+
message = completed.stderr.strip() or completed.stdout.strip() or f"command failed with {completed.returncode}"
|
| 61 |
+
raise RuntimeError(f"{' '.join(command)} failed: {message}")
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def remove_vercel_env(name: str, environment: str) -> CommandResult:
|
| 66 |
+
result = run_command(["vercel", "env", "rm", name, environment, "--yes"], check=False)
|
| 67 |
+
combined = f"{result.stdout}\n{result.stderr}"
|
| 68 |
+
if result.returncode != 0 and "env_not_found" not in combined and "was not found" not in combined:
|
| 69 |
+
message = result.stderr.strip() or result.stdout.strip() or f"command failed with {result.returncode}"
|
| 70 |
+
raise RuntimeError(f"vercel env rm {name} failed: {message}")
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def add_vercel_env(name: str, value: str, environment: str) -> CommandResult:
|
| 75 |
+
return run_command(["vercel", "env", "add", name, environment], input_text=f"{value}\n")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def configure_vercel_worker(
|
| 79 |
+
worker_url: str,
|
| 80 |
+
site_url: str = DEFAULT_SITE_URL,
|
| 81 |
+
code: str = "1234",
|
| 82 |
+
environment: str = "production",
|
| 83 |
+
redeploy: bool = True,
|
| 84 |
+
verify: bool = False,
|
| 85 |
+
timeout: float = 60,
|
| 86 |
+
) -> dict[str, object]:
|
| 87 |
+
worker_url = validate_worker_url(worker_url)
|
| 88 |
+
site_url = normalize_url(site_url)
|
| 89 |
+
commands: list[CommandResult] = []
|
| 90 |
+
|
| 91 |
+
commands.append(remove_vercel_env("WORKER_BASE_URL", environment))
|
| 92 |
+
commands.append(add_vercel_env("WORKER_BASE_URL", worker_url, environment))
|
| 93 |
+
for key in DIRECT_CLOUD_ENV_KEYS:
|
| 94 |
+
commands.append(remove_vercel_env(key, environment))
|
| 95 |
+
if redeploy:
|
| 96 |
+
commands.append(run_command(["vercel", "deploy", "--prod", "--yes"]))
|
| 97 |
+
|
| 98 |
+
site_checks = None
|
| 99 |
+
if verify:
|
| 100 |
+
site_checks = [check.__dict__ for check in verify_site(site_url, code, worker_url, timeout=timeout)]
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
"workerUrl": worker_url,
|
| 104 |
+
"siteUrl": site_url,
|
| 105 |
+
"environment": environment,
|
| 106 |
+
"redeployed": redeploy,
|
| 107 |
+
"verified": verify,
|
| 108 |
+
"commands": [
|
| 109 |
+
{
|
| 110 |
+
"command": result.command,
|
| 111 |
+
"returncode": result.returncode,
|
| 112 |
+
"stdout": result.stdout,
|
| 113 |
+
"stderr": result.stderr,
|
| 114 |
+
}
|
| 115 |
+
for result in commands
|
| 116 |
+
],
|
| 117 |
+
"siteChecks": site_checks,
|
| 118 |
+
"nextCommand": (
|
| 119 |
+
f"python scripts\\prove_live_deployment.py {worker_url} --origin {site_url} "
|
| 120 |
+
f"--code {code} --smoke-ocr-engine arabic-max"
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main_with_args(argv: list[str] | None = None) -> int:
|
| 126 |
+
parser = argparse.ArgumentParser(description="Set Vercel WORKER_BASE_URL, redeploy, and optionally verify the site.")
|
| 127 |
+
parser.add_argument("worker_url", help="Public Hugging Face Space worker URL, e.g. https://user-space.hf.space")
|
| 128 |
+
parser.add_argument("--site-url", default=DEFAULT_SITE_URL)
|
| 129 |
+
parser.add_argument("--code", default="1234")
|
| 130 |
+
parser.add_argument("--environment", default="production")
|
| 131 |
+
parser.add_argument("--no-redeploy", action="store_true")
|
| 132 |
+
parser.add_argument("--verify", action="store_true", help="Run scripts.verify_site after redeploy.")
|
| 133 |
+
parser.add_argument("--timeout", type=float, default=60)
|
| 134 |
+
parser.add_argument("--json", action="store_true")
|
| 135 |
+
args = parser.parse_args(argv)
|
| 136 |
+
|
| 137 |
+
result = configure_vercel_worker(
|
| 138 |
+
args.worker_url,
|
| 139 |
+
site_url=args.site_url,
|
| 140 |
+
code=args.code,
|
| 141 |
+
environment=args.environment,
|
| 142 |
+
redeploy=not args.no_redeploy,
|
| 143 |
+
verify=args.verify,
|
| 144 |
+
timeout=args.timeout,
|
| 145 |
+
)
|
| 146 |
+
if args.json:
|
| 147 |
+
print(json.dumps(result, indent=2))
|
| 148 |
+
else:
|
| 149 |
+
print(f"Configured WORKER_BASE_URL={result['workerUrl']}")
|
| 150 |
+
if result["redeployed"]:
|
| 151 |
+
print("Redeployed Vercel production.")
|
| 152 |
+
print("Next:")
|
| 153 |
+
print(result["nextCommand"])
|
| 154 |
+
site_checks = result.get("siteChecks")
|
| 155 |
+
if site_checks is not None and not all(check["ok"] for check in site_checks):
|
| 156 |
+
return 1
|
| 157 |
+
return 0
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def main() -> int:
|
| 161 |
+
return main_with_args()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
raise SystemExit(main())
|
scripts/deploy_hf_space.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
DEFAULT_BUNDLE_DIR = ROOT_DIR / "outputs" / "huggingface-space"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_env_file(path: Path) -> None:
|
| 15 |
+
if not path.exists():
|
| 16 |
+
return
|
| 17 |
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
| 18 |
+
line = raw_line.strip()
|
| 19 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 20 |
+
continue
|
| 21 |
+
key, value = line.split("=", 1)
|
| 22 |
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def require_huggingface_hub():
|
| 26 |
+
try:
|
| 27 |
+
from huggingface_hub import HfApi, create_repo, upload_folder
|
| 28 |
+
except ImportError as exc:
|
| 29 |
+
raise SystemExit(
|
| 30 |
+
"huggingface_hub is not installed. Run: .\\.venv\\Scripts\\python.exe -m pip install huggingface_hub"
|
| 31 |
+
) from exc
|
| 32 |
+
return HfApi, create_repo, upload_folder
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def run_export(bundle_dir: Path, force_export: bool) -> None:
|
| 36 |
+
if bundle_dir.exists() and not force_export:
|
| 37 |
+
return
|
| 38 |
+
command = [
|
| 39 |
+
sys.executable,
|
| 40 |
+
str(ROOT_DIR / "scripts" / "export_hf_space.py"),
|
| 41 |
+
"--out",
|
| 42 |
+
str(bundle_dir),
|
| 43 |
+
]
|
| 44 |
+
if force_export:
|
| 45 |
+
command.append("--force")
|
| 46 |
+
subprocess.run(command, cwd=ROOT_DIR, check=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_token(explicit_token: str | None) -> str:
|
| 50 |
+
load_env_file(ROOT_DIR / ".env")
|
| 51 |
+
token = explicit_token or os.getenv("HF_TOKEN") or os.getenv("HF_API_TOKEN") or os.getenv("HUGGINGFACE_API_TOKEN")
|
| 52 |
+
if not token:
|
| 53 |
+
raise SystemExit(
|
| 54 |
+
"Missing Hugging Face token. Set HF_TOKEN, HF_API_TOKEN, or HUGGINGFACE_API_TOKEN, "
|
| 55 |
+
"or pass --token. Create a write token at https://huggingface.co/settings/tokens."
|
| 56 |
+
)
|
| 57 |
+
return token
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def token_available(explicit_token: str | None = None) -> bool:
|
| 61 |
+
load_env_file(ROOT_DIR / ".env")
|
| 62 |
+
return bool(explicit_token or os.getenv("HF_TOKEN") or os.getenv("HF_API_TOKEN") or os.getenv("HUGGINGFACE_API_TOKEN"))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def split_repo_id(repo_id: str) -> tuple[str, str]:
|
| 66 |
+
if "/" not in repo_id:
|
| 67 |
+
raise ValueError("Hugging Face Space repo id must look like username/space-name")
|
| 68 |
+
owner, space_name = repo_id.split("/", 1)
|
| 69 |
+
if not owner or not space_name:
|
| 70 |
+
raise ValueError("Hugging Face Space repo id must include both owner and space name")
|
| 71 |
+
return owner, space_name
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def worker_url_for_repo(repo_id: str) -> str:
|
| 75 |
+
owner, space_name = split_repo_id(repo_id)
|
| 76 |
+
return f"https://{owner}-{space_name}.hf.space".lower()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def deploy_hf_space(
|
| 80 |
+
repo_id: str,
|
| 81 |
+
bundle_dir: Path = DEFAULT_BUNDLE_DIR,
|
| 82 |
+
token: str | None = None,
|
| 83 |
+
private: bool = False,
|
| 84 |
+
force_export: bool = False,
|
| 85 |
+
commit_message: str = "Deploy Arabic Audio Reader worker",
|
| 86 |
+
) -> dict[str, object]:
|
| 87 |
+
bundle_dir = bundle_dir.resolve()
|
| 88 |
+
run_export(bundle_dir, force_export)
|
| 89 |
+
hf_token = get_token(token)
|
| 90 |
+
_hf_api, create_repo, upload_folder = require_huggingface_hub()
|
| 91 |
+
|
| 92 |
+
create_repo(
|
| 93 |
+
repo_id=repo_id,
|
| 94 |
+
repo_type="space",
|
| 95 |
+
space_sdk="docker",
|
| 96 |
+
private=private,
|
| 97 |
+
exist_ok=True,
|
| 98 |
+
token=hf_token,
|
| 99 |
+
)
|
| 100 |
+
commit_info = upload_folder(
|
| 101 |
+
repo_id=repo_id,
|
| 102 |
+
repo_type="space",
|
| 103 |
+
folder_path=str(bundle_dir),
|
| 104 |
+
path_in_repo=".",
|
| 105 |
+
commit_message=commit_message,
|
| 106 |
+
token=hf_token,
|
| 107 |
+
)
|
| 108 |
+
worker_url = worker_url_for_repo(repo_id)
|
| 109 |
+
return {
|
| 110 |
+
"repoId": repo_id,
|
| 111 |
+
"workerUrl": worker_url,
|
| 112 |
+
"bundleDir": str(bundle_dir),
|
| 113 |
+
"commitUrl": getattr(commit_info, "commit_url", None),
|
| 114 |
+
"nextCommand": (
|
| 115 |
+
f"python scripts\\deployment_handoff.py {worker_url} "
|
| 116 |
+
"--origin https://arabic-translator-mu.vercel.app --code 1234"
|
| 117 |
+
),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main_with_args(argv: list[str] | None = None) -> int:
|
| 122 |
+
parser = argparse.ArgumentParser(description="Create or update the Hugging Face Docker Space worker.")
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"repo_id",
|
| 125 |
+
help="Hugging Face Space repo id, for example username/arabic-audio-reader-worker.",
|
| 126 |
+
)
|
| 127 |
+
parser.add_argument("--bundle-dir", type=Path, default=DEFAULT_BUNDLE_DIR)
|
| 128 |
+
parser.add_argument("--token", help="Hugging Face write token. Prefer setting HF_TOKEN instead.")
|
| 129 |
+
parser.add_argument("--private", action="store_true", help="Create the Space as private.")
|
| 130 |
+
parser.add_argument("--force-export", action="store_true", help="Rebuild the worker bundle before upload.")
|
| 131 |
+
parser.add_argument("--commit-message", default="Deploy Arabic Audio Reader worker")
|
| 132 |
+
parser.add_argument("--json", action="store_true")
|
| 133 |
+
args = parser.parse_args(argv)
|
| 134 |
+
|
| 135 |
+
result = deploy_hf_space(
|
| 136 |
+
repo_id=args.repo_id,
|
| 137 |
+
bundle_dir=args.bundle_dir,
|
| 138 |
+
token=args.token,
|
| 139 |
+
private=args.private,
|
| 140 |
+
force_export=args.force_export,
|
| 141 |
+
commit_message=args.commit_message,
|
| 142 |
+
)
|
| 143 |
+
if args.json:
|
| 144 |
+
import json
|
| 145 |
+
|
| 146 |
+
print(json.dumps(result, indent=2))
|
| 147 |
+
else:
|
| 148 |
+
print(f"Uploaded worker bundle to https://huggingface.co/spaces/{args.repo_id}")
|
| 149 |
+
print(f"Worker URL: {worker_url}")
|
| 150 |
+
print("Next:")
|
| 151 |
+
print(result["nextCommand"])
|
| 152 |
+
return 0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def main() -> int:
|
| 156 |
+
return main_with_args()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
raise SystemExit(main())
|
scripts/deployment_handoff.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import secrets
|
| 6 |
+
import sys
|
| 7 |
+
from dataclasses import asdict, dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 12 |
+
if str(ROOT_DIR) not in sys.path:
|
| 13 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 14 |
+
|
| 15 |
+
from scripts.next_deployment_step import deployment_url_warnings
|
| 16 |
+
|
| 17 |
+
DEFAULT_OUT = ROOT_DIR / "outputs" / "deployment-handoff.md"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def normalize_origin(value: str) -> str:
|
| 21 |
+
return value.rstrip("/")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def generate_secret_key() -> str:
|
| 25 |
+
return secrets.token_urlsafe(48)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class DeploymentHandoff:
|
| 30 |
+
worker_url: str
|
| 31 |
+
vercel_origin: str
|
| 32 |
+
access_code: str
|
| 33 |
+
urls_look_real: bool
|
| 34 |
+
warnings: list[str]
|
| 35 |
+
recommended_build_args: dict[str, str]
|
| 36 |
+
maximum_quality_build_args: dict[str, str]
|
| 37 |
+
worker_secrets: dict[str, str]
|
| 38 |
+
vercel_env: dict[str, str]
|
| 39 |
+
commands: dict[str, str]
|
| 40 |
+
vercel_cli_commands: list[str]
|
| 41 |
+
vercel_cleanup_commands: list[str]
|
| 42 |
+
huggingface_cli_commands: list[str]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def build_handoff(
|
| 46 |
+
worker_url: str,
|
| 47 |
+
vercel_origin: str,
|
| 48 |
+
access_code: str = "1234",
|
| 49 |
+
secret_key: str | None = None,
|
| 50 |
+
) -> DeploymentHandoff:
|
| 51 |
+
worker_url = normalize_origin(worker_url)
|
| 52 |
+
vercel_origin = normalize_origin(vercel_origin)
|
| 53 |
+
secret_key = secret_key or generate_secret_key()
|
| 54 |
+
warnings = deployment_url_warnings(worker_url, vercel_origin)
|
| 55 |
+
urls_look_real = not warnings
|
| 56 |
+
recommended_build_args = {
|
| 57 |
+
"INSTALL_TAWKEED_OCR": "1",
|
| 58 |
+
"INSTALL_KATIB_OCR": "1",
|
| 59 |
+
"INSTALL_ARABIC_QWEN_OCR": "1",
|
| 60 |
+
"INSTALL_ARABIC_GLM_OCR": "1",
|
| 61 |
+
"INSTALL_BASEER_OCR": "1",
|
| 62 |
+
"INSTALL_SUPERTONIC": "1",
|
| 63 |
+
}
|
| 64 |
+
maximum_quality_build_args = {
|
| 65 |
+
**recommended_build_args,
|
| 66 |
+
"INSTALL_QARI_OCR": "1",
|
| 67 |
+
"INSTALL_PADDLEOCR_VL": "1",
|
| 68 |
+
}
|
| 69 |
+
worker_secrets = {
|
| 70 |
+
"ACCESS_CODE": access_code,
|
| 71 |
+
"SECRET_KEY": secret_key,
|
| 72 |
+
"CORS_ORIGINS": vercel_origin,
|
| 73 |
+
"COOKIE_SAMESITE": "none",
|
| 74 |
+
"COOKIE_SECURE": "1",
|
| 75 |
+
"OCR_ENGINE": "arabic-max",
|
| 76 |
+
"DEFAULT_VOICE_ID": "silma-local",
|
| 77 |
+
"OUTPUT_RETENTION_DAYS": "7",
|
| 78 |
+
"OUTPUT_MAX_FILES": "25",
|
| 79 |
+
"AUDIO_FORMAT": "mp3",
|
| 80 |
+
"MP3_BITRATE": "96k",
|
| 81 |
+
"SILMA_ENABLE_NORMALIZER": "0",
|
| 82 |
+
"SILMA_FORCE_TASHKEEL": "0",
|
| 83 |
+
"SILMA_NORMALIZE_NUMBERS": "0",
|
| 84 |
+
}
|
| 85 |
+
vercel_env = {
|
| 86 |
+
"ACCESS_CODE": access_code,
|
| 87 |
+
"SECRET_KEY": secret_key,
|
| 88 |
+
"WORKER_BASE_URL": worker_url,
|
| 89 |
+
}
|
| 90 |
+
commands = {
|
| 91 |
+
"localReadiness": "python scripts\\prove_local_readiness.py --refresh-research",
|
| 92 |
+
"refreshResearch": "python scripts\\refresh_research_evidence.py",
|
| 93 |
+
"licensePolicy": "python scripts\\research_watchlist.py --check-license-policy --json",
|
| 94 |
+
"researchSources": (
|
| 95 |
+
"python scripts\\check_research_sources.py "
|
| 96 |
+
"--check-hf-metadata --write-hf-metadata-report outputs\\hf-model-metadata.md"
|
| 97 |
+
),
|
| 98 |
+
"exportWorker": "python scripts\\export_hf_space.py --force",
|
| 99 |
+
"prepareDeployment": (
|
| 100 |
+
f"python scripts\\prepare_live_deployment.py --worker-url {worker_url} "
|
| 101 |
+
f"--origin {vercel_origin} --code {access_code}"
|
| 102 |
+
),
|
| 103 |
+
"validateEnv": (
|
| 104 |
+
"python scripts\\validate_deployment_env.py "
|
| 105 |
+
"--vercel-env outputs\\vercel-production.env "
|
| 106 |
+
"--worker-env outputs\\worker-secrets.env "
|
| 107 |
+
f"--worker-url {worker_url} --origin {vercel_origin}"
|
| 108 |
+
),
|
| 109 |
+
"deploymentStatus": (
|
| 110 |
+
f"python scripts\\deployment_status.py --worker-url {worker_url} "
|
| 111 |
+
f"--origin {vercel_origin} --code {access_code}"
|
| 112 |
+
),
|
| 113 |
+
"diagnoseVercelWorker": (
|
| 114 |
+
f"python scripts\\hosted_preflight.py {vercel_origin} "
|
| 115 |
+
f"--code {access_code} --worker-url {worker_url}"
|
| 116 |
+
),
|
| 117 |
+
"configureVercelWorker": (
|
| 118 |
+
f"python scripts\\configure_vercel_worker.py {worker_url} "
|
| 119 |
+
f"--site-url {vercel_origin} --code {access_code} --verify"
|
| 120 |
+
),
|
| 121 |
+
"verifyLive": (
|
| 122 |
+
f"python scripts\\prove_live_deployment.py {worker_url} "
|
| 123 |
+
f"--origin {vercel_origin} --code {access_code} "
|
| 124 |
+
"--smoke-ocr-engine arabic-max "
|
| 125 |
+
"--check-hf-metadata --hf-metadata-report outputs\\hf-model-metadata.md "
|
| 126 |
+
"--proof-out outputs\\live-deployment-proof.json"
|
| 127 |
+
),
|
| 128 |
+
"finalAudit": (
|
| 129 |
+
"python scripts\\audit_goal_readiness.py "
|
| 130 |
+
"--worker-report outputs\\worker-verification.json "
|
| 131 |
+
"--site-report outputs\\site-verification.json"
|
| 132 |
+
),
|
| 133 |
+
}
|
| 134 |
+
vercel_cli_commands = [
|
| 135 |
+
"npm i -g vercel",
|
| 136 |
+
"vercel login",
|
| 137 |
+
"vercel link --yes",
|
| 138 |
+
f'cmd /c "echo {access_code}| vercel env add ACCESS_CODE production"',
|
| 139 |
+
f'cmd /c "echo {secret_key}| vercel env add SECRET_KEY production"',
|
| 140 |
+
f'cmd /c "echo {worker_url}| vercel env add WORKER_BASE_URL production"',
|
| 141 |
+
f"python scripts\\configure_vercel_worker.py {worker_url} --site-url {vercel_origin} --code {access_code} --verify",
|
| 142 |
+
"vercel --prod --yes",
|
| 143 |
+
]
|
| 144 |
+
vercel_cleanup_commands = [
|
| 145 |
+
"vercel env rm ENABLE_DIRECT_CLOUD_TTS production --yes",
|
| 146 |
+
"vercel env rm HF_API_TOKEN production --yes",
|
| 147 |
+
"vercel env rm HF_TTS_MODEL production --yes",
|
| 148 |
+
"vercel env rm DEFAULT_VOICE_ID production --yes",
|
| 149 |
+
]
|
| 150 |
+
huggingface_cli_commands = [
|
| 151 |
+
"python -m pip install -U huggingface_hub",
|
| 152 |
+
"python scripts\\export_hf_space.py --force",
|
| 153 |
+
"python scripts\\deploy_hf_space.py <your-hf-username>/<your-space-name> --bundle-dir outputs/huggingface-space --json",
|
| 154 |
+
]
|
| 155 |
+
return DeploymentHandoff(
|
| 156 |
+
worker_url=worker_url,
|
| 157 |
+
vercel_origin=vercel_origin,
|
| 158 |
+
access_code=access_code,
|
| 159 |
+
urls_look_real=urls_look_real,
|
| 160 |
+
warnings=warnings,
|
| 161 |
+
recommended_build_args=recommended_build_args,
|
| 162 |
+
maximum_quality_build_args=maximum_quality_build_args,
|
| 163 |
+
worker_secrets=worker_secrets,
|
| 164 |
+
vercel_env=vercel_env,
|
| 165 |
+
commands=commands,
|
| 166 |
+
vercel_cli_commands=vercel_cli_commands,
|
| 167 |
+
vercel_cleanup_commands=vercel_cleanup_commands,
|
| 168 |
+
huggingface_cli_commands=huggingface_cli_commands,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def format_env_table(values: dict[str, str]) -> str:
|
| 173 |
+
lines = ["| Name | Value |", "| --- | --- |"]
|
| 174 |
+
lines.extend(f"| `{key}` | `{value}` |" for key, value in values.items())
|
| 175 |
+
return "\n".join(lines)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def format_key_value_block(values: dict[str, str]) -> str:
|
| 179 |
+
return "\n".join(f"{key}={value}" for key, value in values.items())
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def write_markdown(path: Path, handoff: DeploymentHandoff) -> None:
|
| 183 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 184 |
+
path.write_text(
|
| 185 |
+
"\n".join(
|
| 186 |
+
[
|
| 187 |
+
"# Arabic Audio Reader Deployment Handoff",
|
| 188 |
+
"",
|
| 189 |
+
"Use this after the Hugging Face worker URL and Vercel production URL exist.",
|
| 190 |
+
"",
|
| 191 |
+
"## URLs",
|
| 192 |
+
"",
|
| 193 |
+
f"- Worker: {handoff.worker_url}",
|
| 194 |
+
f"- Vercel site: {handoff.vercel_origin}",
|
| 195 |
+
f"- URLs look real: {'yes' if handoff.urls_look_real else 'no'}",
|
| 196 |
+
"",
|
| 197 |
+
*(
|
| 198 |
+
[
|
| 199 |
+
"## URL Warnings",
|
| 200 |
+
"",
|
| 201 |
+
*[f"- {warning}" for warning in handoff.warnings],
|
| 202 |
+
"",
|
| 203 |
+
"Replace placeholder/local/test URLs before running live proof. The final audit will not treat placeholder reports as completed deployment evidence.",
|
| 204 |
+
"",
|
| 205 |
+
]
|
| 206 |
+
if handoff.warnings
|
| 207 |
+
else []
|
| 208 |
+
),
|
| 209 |
+
"## Hugging Face Space Secrets",
|
| 210 |
+
"",
|
| 211 |
+
format_env_table(handoff.worker_secrets),
|
| 212 |
+
"",
|
| 213 |
+
"Copy/paste secret values:",
|
| 214 |
+
"",
|
| 215 |
+
"```text",
|
| 216 |
+
format_key_value_block(handoff.worker_secrets),
|
| 217 |
+
"```",
|
| 218 |
+
"",
|
| 219 |
+
"## Safety Checklist",
|
| 220 |
+
"",
|
| 221 |
+
"- Set the same `ACCESS_CODE` and `SECRET_KEY` on both Hugging Face and Vercel.",
|
| 222 |
+
"- Set Hugging Face `CORS_ORIGINS` to the exact Vercel production origin shown above.",
|
| 223 |
+
"- Set Vercel `WORKER_BASE_URL` to the exact Hugging Face worker URL shown above.",
|
| 224 |
+
"- After both deployments finish, run the Vercel worker diagnostic command below before uploading a large PDF. It must show `site worker reachable from vercel` and `site worker CORS ready`.",
|
| 225 |
+
"- Remove Vercel's temporary direct Hugging Face TTS fallback variables for production: `ENABLE_DIRECT_CLOUD_TTS`, `HF_API_TOKEN`, `HF_TTS_MODEL`, and `DEFAULT_VOICE_ID`.",
|
| 226 |
+
"- Keep `OCR_ENGINE=arabic-max` for scanned Arabic books unless a 5-page benchmark says another engine wins.",
|
| 227 |
+
"- Do not commit this handoff; it contains the deployment `SECRET_KEY`.",
|
| 228 |
+
"",
|
| 229 |
+
"## Hugging Face Docker Build Args",
|
| 230 |
+
"",
|
| 231 |
+
"Balanced Arabic OCR worker for the first strong-worker deployment:",
|
| 232 |
+
"",
|
| 233 |
+
format_env_table(handoff.recommended_build_args),
|
| 234 |
+
"",
|
| 235 |
+
"Copy/paste balanced build args:",
|
| 236 |
+
"",
|
| 237 |
+
"```text",
|
| 238 |
+
format_key_value_block(handoff.recommended_build_args),
|
| 239 |
+
"```",
|
| 240 |
+
"",
|
| 241 |
+
"Maximum quality worker for a GPU or larger paid/owned worker after a 5-page benchmark proves the heavier models help:",
|
| 242 |
+
"",
|
| 243 |
+
format_env_table(handoff.maximum_quality_build_args),
|
| 244 |
+
"",
|
| 245 |
+
"Copy/paste maximum-quality build args:",
|
| 246 |
+
"",
|
| 247 |
+
"```text",
|
| 248 |
+
format_key_value_block(handoff.maximum_quality_build_args),
|
| 249 |
+
"```",
|
| 250 |
+
"",
|
| 251 |
+
"## Vercel Environment Variables",
|
| 252 |
+
"",
|
| 253 |
+
format_env_table(handoff.vercel_env),
|
| 254 |
+
"",
|
| 255 |
+
"Copy/paste Vercel values:",
|
| 256 |
+
"",
|
| 257 |
+
"```text",
|
| 258 |
+
format_key_value_block(handoff.vercel_env),
|
| 259 |
+
"```",
|
| 260 |
+
"",
|
| 261 |
+
"## Deploy Worker With Hugging Face CLI",
|
| 262 |
+
"",
|
| 263 |
+
"Create a Hugging Face Space with SDK **Docker**, then run these from the repo root. Replace `<your-hf-username>/<your-space-name>` with the Space repo id:",
|
| 264 |
+
"",
|
| 265 |
+
"```powershell",
|
| 266 |
+
*handoff.huggingface_cli_commands,
|
| 267 |
+
"```",
|
| 268 |
+
"",
|
| 269 |
+
"Set the Hugging Face Space secrets and Docker build args from the tables above in the Space settings before the final smoke test.",
|
| 270 |
+
"The `SECRET_KEY` value in this handoff is generated for this deployment; keep it private and do not commit the handoff output.",
|
| 271 |
+
"",
|
| 272 |
+
"## Deploy Vercel Site With CLI",
|
| 273 |
+
"",
|
| 274 |
+
"Run these from the repo root after the worker URL is known:",
|
| 275 |
+
"",
|
| 276 |
+
"```powershell",
|
| 277 |
+
*handoff.vercel_cli_commands,
|
| 278 |
+
"```",
|
| 279 |
+
"",
|
| 280 |
+
"If you previously tested direct Hugging Face cloud TTS on Vercel, remove those temporary variables before or after setting `WORKER_BASE_URL`. It is normal if a remove command says the variable does not exist:",
|
| 281 |
+
"",
|
| 282 |
+
"```powershell",
|
| 283 |
+
*handoff.vercel_cleanup_commands,
|
| 284 |
+
"vercel --prod --yes",
|
| 285 |
+
"```",
|
| 286 |
+
"",
|
| 287 |
+
"## Commands",
|
| 288 |
+
"",
|
| 289 |
+
"Run these from the repo root:",
|
| 290 |
+
"",
|
| 291 |
+
"```powershell",
|
| 292 |
+
handoff.commands["localReadiness"],
|
| 293 |
+
handoff.commands["refreshResearch"],
|
| 294 |
+
handoff.commands["licensePolicy"],
|
| 295 |
+
handoff.commands["researchSources"],
|
| 296 |
+
handoff.commands["exportWorker"],
|
| 297 |
+
handoff.commands["prepareDeployment"],
|
| 298 |
+
handoff.commands["validateEnv"],
|
| 299 |
+
handoff.commands["deploymentStatus"],
|
| 300 |
+
handoff.commands["diagnoseVercelWorker"],
|
| 301 |
+
handoff.commands["verifyLive"],
|
| 302 |
+
handoff.commands["finalAudit"],
|
| 303 |
+
"```",
|
| 304 |
+
"",
|
| 305 |
+
"The hosted preflight command checks the hosted site's `/api/worker-diagnostics` endpoint and writes `outputs\\hosted-preflight.json`. If it says `cors-blocked`, set Hugging Face `CORS_ORIGINS` to the exact Vercel production URL, keep `COOKIE_SAMESITE=none` and `COOKIE_SECURE=1`, restart the Space, redeploy Vercel, and run the preflight again.",
|
| 306 |
+
"",
|
| 307 |
+
"The goal audit is complete only after `outputs\\site-verification.json` proves the Vercel shell login, worker routing, large-PDF readiness, hosted provider limits, recommended stack, disabled direct cloud fallback, worker diagnostics reachability, and worker CORS readiness; `outputs\\worker-verification.json` proves the worker recommended stack plus embedded-text and scanned-OCR smoke jobs, usable extracted text, scanned OCR extraction, audio URLs, download URLs, audio bytes, download bytes, and audio file signatures; and `outputs\\live-deployment-proof.json` records `complete: true`.",
|
| 308 |
+
"",
|
| 309 |
+
]
|
| 310 |
+
),
|
| 311 |
+
encoding="utf-8",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def main() -> None:
|
| 316 |
+
parser = argparse.ArgumentParser(description="Create a deployment handoff file with exact worker/Vercel settings and proof commands.")
|
| 317 |
+
parser.add_argument("worker_url", help="Worker URL, for example https://your-space.hf.space")
|
| 318 |
+
parser.add_argument("--origin", required=True, help="Vercel production origin, for example https://your-app.vercel.app")
|
| 319 |
+
parser.add_argument("--code", default="1234", help="Access code for the site and worker.")
|
| 320 |
+
parser.add_argument("--secret-key", help="Optional fixed cookie-signing secret. Omit to generate a random deployment secret.")
|
| 321 |
+
parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="Markdown handoff destination.")
|
| 322 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a short summary.")
|
| 323 |
+
args = parser.parse_args()
|
| 324 |
+
|
| 325 |
+
handoff = build_handoff(args.worker_url, args.origin, args.code, args.secret_key)
|
| 326 |
+
write_markdown(args.out, handoff)
|
| 327 |
+
result = {"out": str(args.out), **asdict(handoff)}
|
| 328 |
+
if args.json:
|
| 329 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 330 |
+
else:
|
| 331 |
+
print(f"Wrote deployment handoff to {args.out}")
|
| 332 |
+
print(handoff.commands["verifyLive"])
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
if __name__ == "__main__":
|
| 336 |
+
main()
|
scripts/deployment_status.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
if str(ROOT_DIR) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 13 |
+
|
| 14 |
+
from scripts.next_deployment_step import (
|
| 15 |
+
LIVE_PROOF,
|
| 16 |
+
LOCAL_REPORT,
|
| 17 |
+
SITE_REPORT,
|
| 18 |
+
WORKER_REPORT,
|
| 19 |
+
choose_next_step,
|
| 20 |
+
deployment_urls_look_real,
|
| 21 |
+
load_json,
|
| 22 |
+
local_ready,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ReportStatus:
|
| 28 |
+
path: str
|
| 29 |
+
exists: bool
|
| 30 |
+
valid_json: bool
|
| 31 |
+
summary: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class DeploymentStatus:
|
| 36 |
+
ready_for_live_urls: bool
|
| 37 |
+
live_urls_look_real: bool
|
| 38 |
+
live_complete: bool
|
| 39 |
+
reports_are_real_live_evidence: bool
|
| 40 |
+
local_report: ReportStatus
|
| 41 |
+
site_report: ReportStatus
|
| 42 |
+
worker_report: ReportStatus
|
| 43 |
+
live_proof: ReportStatus
|
| 44 |
+
missing_site_checks: list[str]
|
| 45 |
+
missing_worker_checks: list[str]
|
| 46 |
+
next_status: str
|
| 47 |
+
next_title: str
|
| 48 |
+
next_detail: str
|
| 49 |
+
next_command: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _report_status(path: Path) -> ReportStatus:
|
| 53 |
+
payload = load_json(path)
|
| 54 |
+
if payload is None:
|
| 55 |
+
return ReportStatus(str(path), path.exists(), False, "missing or invalid JSON")
|
| 56 |
+
return ReportStatus(str(path), True, True, summarize_payload(path, payload))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _list_checks_ok(payload: Any, names: set[str]) -> bool:
|
| 60 |
+
if not isinstance(payload, list):
|
| 61 |
+
return False
|
| 62 |
+
checks = {str(item.get("name", "")): bool(item.get("ok")) for item in payload if isinstance(item, dict)}
|
| 63 |
+
return all(checks.get(name) is True for name in names)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _missing_checks(payload: Any, names: set[str]) -> list[str]:
|
| 67 |
+
if not isinstance(payload, list):
|
| 68 |
+
return sorted(names)
|
| 69 |
+
checks = {str(item.get("name", "")): bool(item.get("ok")) for item in payload if isinstance(item, dict)}
|
| 70 |
+
return sorted(name for name in names if checks.get(name) is not True)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _looks_like_test_artifact(payload: Any) -> bool:
|
| 74 |
+
if not isinstance(payload, dict):
|
| 75 |
+
return False
|
| 76 |
+
text = json.dumps(payload, ensure_ascii=False).lower()
|
| 77 |
+
return "pytest-" in text or "your-space.hf.space" in text or "your-vercel-app.vercel.app" in text
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _url_evidence_matches(live_payload: Any, worker_url: str | None, vercel_origin: str | None) -> bool:
|
| 81 |
+
if not isinstance(live_payload, dict):
|
| 82 |
+
return False
|
| 83 |
+
proof_worker = str(live_payload.get("workerUrl") or "").rstrip("/")
|
| 84 |
+
proof_origin = str(live_payload.get("origin") or "").rstrip("/")
|
| 85 |
+
expected_worker = (worker_url or proof_worker).rstrip("/")
|
| 86 |
+
expected_origin = (vercel_origin or proof_origin).rstrip("/")
|
| 87 |
+
if not deployment_urls_look_real(expected_worker, expected_origin):
|
| 88 |
+
return False
|
| 89 |
+
return proof_worker == expected_worker and proof_origin == expected_origin
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def summarize_payload(path: Path, payload: Any) -> str:
|
| 93 |
+
if isinstance(payload, dict):
|
| 94 |
+
if "ready" in payload or "complete" in payload:
|
| 95 |
+
return f"ready={payload.get('ready')} complete={payload.get('complete')}"
|
| 96 |
+
if "counts" in payload:
|
| 97 |
+
return f"counts={payload.get('counts')}"
|
| 98 |
+
return f"object keys={','.join(sorted(str(key) for key in payload.keys())[:6])}"
|
| 99 |
+
if isinstance(payload, list):
|
| 100 |
+
passed = sum(1 for item in payload if isinstance(item, dict) and item.get("ok") is True)
|
| 101 |
+
failed = sum(1 for item in payload if isinstance(item, dict) and item.get("ok") is False)
|
| 102 |
+
return f"checks={len(payload)} pass={passed} fail={failed}"
|
| 103 |
+
return type(payload).__name__
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def build_status(
|
| 107 |
+
worker_url: str | None = None,
|
| 108 |
+
vercel_origin: str | None = None,
|
| 109 |
+
code: str = "1234",
|
| 110 |
+
local_report: Path = LOCAL_REPORT,
|
| 111 |
+
site_report: Path = SITE_REPORT,
|
| 112 |
+
worker_report: Path = WORKER_REPORT,
|
| 113 |
+
live_proof: Path = LIVE_PROOF,
|
| 114 |
+
) -> DeploymentStatus:
|
| 115 |
+
site_required = {
|
| 116 |
+
"site login",
|
| 117 |
+
"site platform vercel",
|
| 118 |
+
"site worker configured",
|
| 119 |
+
"site large PDF ready",
|
| 120 |
+
"site production worker ready",
|
| 121 |
+
"site hosted limits documented",
|
| 122 |
+
"site recommended stack documented",
|
| 123 |
+
"site direct cloud fallback disabled",
|
| 124 |
+
"site worker diagnostics endpoint",
|
| 125 |
+
"site worker reachable from vercel",
|
| 126 |
+
"site worker CORS ready",
|
| 127 |
+
}
|
| 128 |
+
worker_required = {
|
| 129 |
+
"recommended stack documented",
|
| 130 |
+
"smoke upload accepted",
|
| 131 |
+
"smoke job complete",
|
| 132 |
+
"smoke usable text",
|
| 133 |
+
"smoke audio url",
|
| 134 |
+
"smoke download url",
|
| 135 |
+
"smoke audio bytes",
|
| 136 |
+
"smoke audio file signature",
|
| 137 |
+
"smoke download bytes",
|
| 138 |
+
"smoke download file signature",
|
| 139 |
+
"scanned smoke upload accepted",
|
| 140 |
+
"scanned smoke job complete",
|
| 141 |
+
"scanned smoke usable text",
|
| 142 |
+
"scanned smoke OCR extraction",
|
| 143 |
+
"scanned smoke audio url",
|
| 144 |
+
"scanned smoke download url",
|
| 145 |
+
"scanned smoke audio bytes",
|
| 146 |
+
"scanned smoke audio file signature",
|
| 147 |
+
"scanned smoke download bytes",
|
| 148 |
+
"scanned smoke download file signature",
|
| 149 |
+
}
|
| 150 |
+
live_payload = load_json(live_proof)
|
| 151 |
+
site_payload = load_json(site_report)
|
| 152 |
+
worker_payload = load_json(worker_report)
|
| 153 |
+
missing_site_checks = _missing_checks(site_payload, site_required)
|
| 154 |
+
missing_worker_checks = _missing_checks(worker_payload, worker_required)
|
| 155 |
+
live_complete = isinstance(live_payload, dict) and bool(live_payload.get("complete"))
|
| 156 |
+
live_urls_look_real = deployment_urls_look_real(worker_url, vercel_origin)
|
| 157 |
+
reports_are_real_live_evidence = bool(
|
| 158 |
+
live_complete
|
| 159 |
+
and not missing_site_checks
|
| 160 |
+
and not missing_worker_checks
|
| 161 |
+
and not _looks_like_test_artifact(live_payload)
|
| 162 |
+
and _url_evidence_matches(live_payload, worker_url, vercel_origin)
|
| 163 |
+
)
|
| 164 |
+
next_step = choose_next_step(
|
| 165 |
+
worker_url=worker_url,
|
| 166 |
+
vercel_origin=vercel_origin,
|
| 167 |
+
code=code,
|
| 168 |
+
local_report=local_report,
|
| 169 |
+
site_report=site_report,
|
| 170 |
+
worker_report=worker_report,
|
| 171 |
+
live_proof=live_proof,
|
| 172 |
+
)
|
| 173 |
+
if live_complete and not reports_are_real_live_evidence:
|
| 174 |
+
next_status = "live-proof"
|
| 175 |
+
next_title = "Re-run live proof against real deployed URLs"
|
| 176 |
+
next_detail = (
|
| 177 |
+
"A complete live proof file exists, but it does not look like trustworthy live evidence. "
|
| 178 |
+
"Re-run the proof after replacing placeholder/test URLs with the real Hugging Face worker and Vercel site."
|
| 179 |
+
)
|
| 180 |
+
next_command = (
|
| 181 |
+
f"python scripts\\prove_live_deployment.py {worker_url or 'https://your-space.hf.space'} "
|
| 182 |
+
f"--origin {vercel_origin or 'https://your-vercel-app.vercel.app'} --code {code} "
|
| 183 |
+
"--smoke-ocr-engine arabic-max "
|
| 184 |
+
"--check-hf-metadata --hf-metadata-report outputs\\hf-model-metadata.md "
|
| 185 |
+
"--proof-out outputs\\live-deployment-proof.json"
|
| 186 |
+
)
|
| 187 |
+
else:
|
| 188 |
+
next_status = next_step.status
|
| 189 |
+
next_title = next_step.title
|
| 190 |
+
next_detail = next_step.detail
|
| 191 |
+
next_command = next_step.command
|
| 192 |
+
return DeploymentStatus(
|
| 193 |
+
ready_for_live_urls=local_ready(local_report) and live_urls_look_real,
|
| 194 |
+
live_urls_look_real=live_urls_look_real,
|
| 195 |
+
live_complete=live_complete,
|
| 196 |
+
reports_are_real_live_evidence=reports_are_real_live_evidence,
|
| 197 |
+
local_report=_report_status(local_report),
|
| 198 |
+
site_report=_report_status(site_report),
|
| 199 |
+
worker_report=_report_status(worker_report),
|
| 200 |
+
live_proof=_report_status(live_proof),
|
| 201 |
+
missing_site_checks=missing_site_checks,
|
| 202 |
+
missing_worker_checks=missing_worker_checks,
|
| 203 |
+
next_status=next_status,
|
| 204 |
+
next_title=next_title,
|
| 205 |
+
next_detail=next_detail,
|
| 206 |
+
next_command=next_command,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def print_status(status: DeploymentStatus) -> None:
|
| 211 |
+
print(f"Ready for live URLs: {status.ready_for_live_urls}")
|
| 212 |
+
print(f"Live URLs look real: {status.live_urls_look_real}")
|
| 213 |
+
print(f"Live proof complete: {status.live_complete}")
|
| 214 |
+
print(f"Reports are real live evidence: {status.reports_are_real_live_evidence}")
|
| 215 |
+
print()
|
| 216 |
+
for label, report in [
|
| 217 |
+
("Local", status.local_report),
|
| 218 |
+
("Site", status.site_report),
|
| 219 |
+
("Worker", status.worker_report),
|
| 220 |
+
("Live proof", status.live_proof),
|
| 221 |
+
]:
|
| 222 |
+
print(f"{label}: {report.summary} ({report.path})")
|
| 223 |
+
if status.missing_site_checks:
|
| 224 |
+
print()
|
| 225 |
+
print("Missing site proof checks:")
|
| 226 |
+
for name in status.missing_site_checks:
|
| 227 |
+
print(f"- {name}")
|
| 228 |
+
if status.missing_worker_checks:
|
| 229 |
+
print()
|
| 230 |
+
print("Missing worker proof checks:")
|
| 231 |
+
for name in status.missing_worker_checks:
|
| 232 |
+
print(f"- {name}")
|
| 233 |
+
print()
|
| 234 |
+
print(status.next_title)
|
| 235 |
+
print(status.next_detail)
|
| 236 |
+
print()
|
| 237 |
+
print(status.next_command)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def main() -> None:
|
| 241 |
+
parser = argparse.ArgumentParser(description="Show the current deployment proof status and next command.")
|
| 242 |
+
parser.add_argument("--worker-url", help="Live worker URL, for example https://your-space.hf.space")
|
| 243 |
+
parser.add_argument("--origin", help="Live Vercel origin, for example https://your-app.vercel.app")
|
| 244 |
+
parser.add_argument("--code", default="1234", help="Access code for live verification.")
|
| 245 |
+
parser.add_argument("--json", action="store_true", help="Print JSON.")
|
| 246 |
+
args = parser.parse_args()
|
| 247 |
+
|
| 248 |
+
status = build_status(worker_url=args.worker_url, vercel_origin=args.origin, code=args.code)
|
| 249 |
+
if args.json:
|
| 250 |
+
print(json.dumps(asdict(status), ensure_ascii=False, indent=2))
|
| 251 |
+
else:
|
| 252 |
+
print_status(status)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
if __name__ == "__main__":
|
| 256 |
+
main()
|
scripts/dry_run_pdf.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Iterator
|
| 9 |
+
|
| 10 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
if str(ROOT_DIR) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 13 |
+
|
| 14 |
+
from app import main
|
| 15 |
+
from scripts.benchmark_ocr import recommendation_for_extraction
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
OCR_ENV_KEYS = {
|
| 19 |
+
"OCR_ENGINE",
|
| 20 |
+
"OCR_RENDER_ZOOM",
|
| 21 |
+
"EASYOCR_RENDER_ZOOM",
|
| 22 |
+
"QARI_OCR_RENDER_ZOOM",
|
| 23 |
+
"TAWKEED_OCR_RENDER_ZOOM",
|
| 24 |
+
"KATIB_OCR_RENDER_ZOOM",
|
| 25 |
+
"ARABIC_QWEN_OCR_RENDER_ZOOM",
|
| 26 |
+
"ARABIC_GLM_OCR_RENDER_ZOOM",
|
| 27 |
+
"BASEER_OCR_RENDER_ZOOM",
|
| 28 |
+
"PADDLEOCR_RENDER_ZOOM",
|
| 29 |
+
"PADDLEOCR_VL_RENDER_ZOOM",
|
| 30 |
+
"SURYA_RENDER_ZOOM",
|
| 31 |
+
"TESSERACT_PSM",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_ocr_env_file(path: Path | None) -> dict[str, str]:
|
| 36 |
+
if path is None:
|
| 37 |
+
return {}
|
| 38 |
+
if not path.exists():
|
| 39 |
+
raise FileNotFoundError(f"OCR env file not found: {path}")
|
| 40 |
+
values: dict[str, str] = {}
|
| 41 |
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
| 42 |
+
line = raw_line.strip()
|
| 43 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 44 |
+
continue
|
| 45 |
+
key, value = line.split("=", 1)
|
| 46 |
+
key = key.strip()
|
| 47 |
+
if key in OCR_ENV_KEYS:
|
| 48 |
+
values[key] = value.strip().strip('"').strip("'")
|
| 49 |
+
return values
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@contextmanager
|
| 53 |
+
def temporary_ocr_settings(
|
| 54 |
+
ocr_engine: str | None = None,
|
| 55 |
+
ocr_render_zoom: str | None = None,
|
| 56 |
+
easyocr_render_zoom: str | None = None,
|
| 57 |
+
qari_ocr_render_zoom: str | None = None,
|
| 58 |
+
tawkeed_ocr_render_zoom: str | None = None,
|
| 59 |
+
katib_ocr_render_zoom: str | None = None,
|
| 60 |
+
arabic_qwen_ocr_render_zoom: str | None = None,
|
| 61 |
+
arabic_glm_ocr_render_zoom: str | None = None,
|
| 62 |
+
baseer_ocr_render_zoom: str | None = None,
|
| 63 |
+
paddleocr_render_zoom: str | None = None,
|
| 64 |
+
paddleocr_vl_render_zoom: str | None = None,
|
| 65 |
+
surya_render_zoom: str | None = None,
|
| 66 |
+
tesseract_psm: str | None = None,
|
| 67 |
+
from_extraction: str | None = None,
|
| 68 |
+
env_file: Path | None = None,
|
| 69 |
+
) -> Iterator[None]:
|
| 70 |
+
file_env = load_ocr_env_file(env_file)
|
| 71 |
+
extraction_env: dict[str, str] = {}
|
| 72 |
+
if from_extraction:
|
| 73 |
+
recommendation = recommendation_for_extraction(from_extraction)
|
| 74 |
+
extraction_env = recommendation.get("env", {}) if recommendation else {}
|
| 75 |
+
ocr_engine = ocr_engine or extraction_env.get("OCR_ENGINE") or file_env.get("OCR_ENGINE")
|
| 76 |
+
ocr_render_zoom = ocr_render_zoom or extraction_env.get("OCR_RENDER_ZOOM") or file_env.get("OCR_RENDER_ZOOM")
|
| 77 |
+
easyocr_render_zoom = easyocr_render_zoom or extraction_env.get("EASYOCR_RENDER_ZOOM") or file_env.get("EASYOCR_RENDER_ZOOM")
|
| 78 |
+
qari_ocr_render_zoom = qari_ocr_render_zoom or extraction_env.get("QARI_OCR_RENDER_ZOOM") or file_env.get("QARI_OCR_RENDER_ZOOM")
|
| 79 |
+
tawkeed_ocr_render_zoom = (
|
| 80 |
+
tawkeed_ocr_render_zoom
|
| 81 |
+
or extraction_env.get("TAWKEED_OCR_RENDER_ZOOM")
|
| 82 |
+
or file_env.get("TAWKEED_OCR_RENDER_ZOOM")
|
| 83 |
+
)
|
| 84 |
+
katib_ocr_render_zoom = (
|
| 85 |
+
katib_ocr_render_zoom or extraction_env.get("KATIB_OCR_RENDER_ZOOM") or file_env.get("KATIB_OCR_RENDER_ZOOM")
|
| 86 |
+
)
|
| 87 |
+
arabic_qwen_ocr_render_zoom = (
|
| 88 |
+
arabic_qwen_ocr_render_zoom
|
| 89 |
+
or extraction_env.get("ARABIC_QWEN_OCR_RENDER_ZOOM")
|
| 90 |
+
or file_env.get("ARABIC_QWEN_OCR_RENDER_ZOOM")
|
| 91 |
+
)
|
| 92 |
+
arabic_glm_ocr_render_zoom = (
|
| 93 |
+
arabic_glm_ocr_render_zoom
|
| 94 |
+
or extraction_env.get("ARABIC_GLM_OCR_RENDER_ZOOM")
|
| 95 |
+
or file_env.get("ARABIC_GLM_OCR_RENDER_ZOOM")
|
| 96 |
+
)
|
| 97 |
+
baseer_ocr_render_zoom = (
|
| 98 |
+
baseer_ocr_render_zoom or extraction_env.get("BASEER_OCR_RENDER_ZOOM") or file_env.get("BASEER_OCR_RENDER_ZOOM")
|
| 99 |
+
)
|
| 100 |
+
paddleocr_render_zoom = paddleocr_render_zoom or extraction_env.get("PADDLEOCR_RENDER_ZOOM") or file_env.get("PADDLEOCR_RENDER_ZOOM")
|
| 101 |
+
paddleocr_vl_render_zoom = (
|
| 102 |
+
paddleocr_vl_render_zoom
|
| 103 |
+
or extraction_env.get("PADDLEOCR_VL_RENDER_ZOOM")
|
| 104 |
+
or file_env.get("PADDLEOCR_VL_RENDER_ZOOM")
|
| 105 |
+
)
|
| 106 |
+
surya_render_zoom = surya_render_zoom or extraction_env.get("SURYA_RENDER_ZOOM") or file_env.get("SURYA_RENDER_ZOOM")
|
| 107 |
+
tesseract_psm = tesseract_psm or extraction_env.get("TESSERACT_PSM") or file_env.get("TESSERACT_PSM")
|
| 108 |
+
|
| 109 |
+
previous_engine = main.OCR_ENGINE
|
| 110 |
+
previous_env = {
|
| 111 |
+
"OCR_RENDER_ZOOM": main.os.getenv("OCR_RENDER_ZOOM"),
|
| 112 |
+
"EASYOCR_RENDER_ZOOM": main.os.getenv("EASYOCR_RENDER_ZOOM"),
|
| 113 |
+
"QARI_OCR_RENDER_ZOOM": main.os.getenv("QARI_OCR_RENDER_ZOOM"),
|
| 114 |
+
"TAWKEED_OCR_RENDER_ZOOM": main.os.getenv("TAWKEED_OCR_RENDER_ZOOM"),
|
| 115 |
+
"KATIB_OCR_RENDER_ZOOM": main.os.getenv("KATIB_OCR_RENDER_ZOOM"),
|
| 116 |
+
"ARABIC_QWEN_OCR_RENDER_ZOOM": main.os.getenv("ARABIC_QWEN_OCR_RENDER_ZOOM"),
|
| 117 |
+
"ARABIC_GLM_OCR_RENDER_ZOOM": main.os.getenv("ARABIC_GLM_OCR_RENDER_ZOOM"),
|
| 118 |
+
"BASEER_OCR_RENDER_ZOOM": main.os.getenv("BASEER_OCR_RENDER_ZOOM"),
|
| 119 |
+
"PADDLEOCR_RENDER_ZOOM": main.os.getenv("PADDLEOCR_RENDER_ZOOM"),
|
| 120 |
+
"PADDLEOCR_VL_RENDER_ZOOM": main.os.getenv("PADDLEOCR_VL_RENDER_ZOOM"),
|
| 121 |
+
"SURYA_RENDER_ZOOM": main.os.getenv("SURYA_RENDER_ZOOM"),
|
| 122 |
+
"TESSERACT_PSM": main.os.getenv("TESSERACT_PSM"),
|
| 123 |
+
}
|
| 124 |
+
try:
|
| 125 |
+
if ocr_engine is not None:
|
| 126 |
+
main.OCR_ENGINE = main.normalize_ocr_engine(ocr_engine)
|
| 127 |
+
for key, value in {
|
| 128 |
+
"OCR_RENDER_ZOOM": ocr_render_zoom,
|
| 129 |
+
"EASYOCR_RENDER_ZOOM": easyocr_render_zoom,
|
| 130 |
+
"QARI_OCR_RENDER_ZOOM": qari_ocr_render_zoom,
|
| 131 |
+
"TAWKEED_OCR_RENDER_ZOOM": tawkeed_ocr_render_zoom,
|
| 132 |
+
"KATIB_OCR_RENDER_ZOOM": katib_ocr_render_zoom,
|
| 133 |
+
"ARABIC_QWEN_OCR_RENDER_ZOOM": arabic_qwen_ocr_render_zoom,
|
| 134 |
+
"ARABIC_GLM_OCR_RENDER_ZOOM": arabic_glm_ocr_render_zoom,
|
| 135 |
+
"BASEER_OCR_RENDER_ZOOM": baseer_ocr_render_zoom,
|
| 136 |
+
"PADDLEOCR_RENDER_ZOOM": paddleocr_render_zoom,
|
| 137 |
+
"PADDLEOCR_VL_RENDER_ZOOM": paddleocr_vl_render_zoom,
|
| 138 |
+
"SURYA_RENDER_ZOOM": surya_render_zoom,
|
| 139 |
+
"TESSERACT_PSM": tesseract_psm,
|
| 140 |
+
}.items():
|
| 141 |
+
if value is not None:
|
| 142 |
+
main.os.environ[key] = value
|
| 143 |
+
yield
|
| 144 |
+
finally:
|
| 145 |
+
main.OCR_ENGINE = previous_engine
|
| 146 |
+
for key, value in previous_env.items():
|
| 147 |
+
if value is None:
|
| 148 |
+
main.os.environ.pop(key, None)
|
| 149 |
+
else:
|
| 150 |
+
main.os.environ[key] = value
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def dry_run_pdf(
|
| 154 |
+
pdf_path: Path,
|
| 155 |
+
chunk_size: int,
|
| 156 |
+
ocr_engine: str | None = None,
|
| 157 |
+
ocr_render_zoom: str | None = None,
|
| 158 |
+
easyocr_render_zoom: str | None = None,
|
| 159 |
+
qari_ocr_render_zoom: str | None = None,
|
| 160 |
+
tawkeed_ocr_render_zoom: str | None = None,
|
| 161 |
+
katib_ocr_render_zoom: str | None = None,
|
| 162 |
+
arabic_qwen_ocr_render_zoom: str | None = None,
|
| 163 |
+
arabic_glm_ocr_render_zoom: str | None = None,
|
| 164 |
+
baseer_ocr_render_zoom: str | None = None,
|
| 165 |
+
paddleocr_render_zoom: str | None = None,
|
| 166 |
+
paddleocr_vl_render_zoom: str | None = None,
|
| 167 |
+
surya_render_zoom: str | None = None,
|
| 168 |
+
tesseract_psm: str | None = None,
|
| 169 |
+
from_extraction: str | None = None,
|
| 170 |
+
env_file: Path | None = None,
|
| 171 |
+
include_speech_text: bool = False,
|
| 172 |
+
speech_sample_chars: int | None = 1200,
|
| 173 |
+
) -> dict[str, object]:
|
| 174 |
+
if not pdf_path.exists():
|
| 175 |
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
| 176 |
+
if pdf_path.suffix.lower() != ".pdf":
|
| 177 |
+
raise ValueError("Dry run input must be a PDF file.")
|
| 178 |
+
|
| 179 |
+
with temporary_ocr_settings(
|
| 180 |
+
ocr_engine=ocr_engine,
|
| 181 |
+
ocr_render_zoom=ocr_render_zoom,
|
| 182 |
+
easyocr_render_zoom=easyocr_render_zoom,
|
| 183 |
+
qari_ocr_render_zoom=qari_ocr_render_zoom,
|
| 184 |
+
tawkeed_ocr_render_zoom=tawkeed_ocr_render_zoom,
|
| 185 |
+
katib_ocr_render_zoom=katib_ocr_render_zoom,
|
| 186 |
+
arabic_qwen_ocr_render_zoom=arabic_qwen_ocr_render_zoom,
|
| 187 |
+
arabic_glm_ocr_render_zoom=arabic_glm_ocr_render_zoom,
|
| 188 |
+
baseer_ocr_render_zoom=baseer_ocr_render_zoom,
|
| 189 |
+
paddleocr_render_zoom=paddleocr_render_zoom,
|
| 190 |
+
paddleocr_vl_render_zoom=paddleocr_vl_render_zoom,
|
| 191 |
+
surya_render_zoom=surya_render_zoom,
|
| 192 |
+
tesseract_psm=tesseract_psm,
|
| 193 |
+
from_extraction=from_extraction,
|
| 194 |
+
env_file=env_file,
|
| 195 |
+
):
|
| 196 |
+
job = main.Job(id="dry-run", filename=pdf_path.name, ocr_engine=ocr_engine or main.OCR_ENGINE)
|
| 197 |
+
text = main.extract_pdf_text(pdf_path, job)
|
| 198 |
+
speech_text = main.prepare_text_for_speech(text)
|
| 199 |
+
chunks = main.chunk_text(speech_text, chunk_size=chunk_size)
|
| 200 |
+
quality = main.assess_text_quality(text, speech_text)
|
| 201 |
+
placeholder_count = speech_text.count("?") + speech_text.count("\ufffd")
|
| 202 |
+
speech_sample = speech_text
|
| 203 |
+
if speech_sample_chars is not None and speech_sample_chars > 0:
|
| 204 |
+
speech_sample = speech_text[:speech_sample_chars].rstrip()
|
| 205 |
+
result: dict[str, object] = {
|
| 206 |
+
"pdf": str(pdf_path),
|
| 207 |
+
"pages": job.pages,
|
| 208 |
+
"characters": len(text),
|
| 209 |
+
"speechCharacters": len(speech_text),
|
| 210 |
+
"arabicWords": quality["arabicWords"],
|
| 211 |
+
"placeholderCharacters": placeholder_count,
|
| 212 |
+
"placeholderRatio": quality["placeholderRatio"],
|
| 213 |
+
"singleArabicWords": int(quality["metrics"]["singleArabicWords"]),
|
| 214 |
+
"singleArabicWordRatio": quality["metrics"]["singleArabicWordRatio"],
|
| 215 |
+
"fragmentLines": int(quality["metrics"]["fragmentLines"]),
|
| 216 |
+
"fragmentLineRatio": quality["metrics"]["fragmentLineRatio"],
|
| 217 |
+
"quality": quality["quality"],
|
| 218 |
+
"qualityScore": quality["score"],
|
| 219 |
+
"qualityReasons": quality["reasons"],
|
| 220 |
+
"extraction": job.extraction,
|
| 221 |
+
"ocrEngine": job.ocr_engine,
|
| 222 |
+
"chunks": len(chunks),
|
| 223 |
+
"chunkSize": chunk_size,
|
| 224 |
+
"largestChunkCharacters": max((len(chunk) for chunk in chunks), default=0),
|
| 225 |
+
"textPreview": text[:160],
|
| 226 |
+
"speechPreview": speech_text[:160],
|
| 227 |
+
"speechSampleText": speech_sample,
|
| 228 |
+
"readyForTts": bool(chunks and quality["readyForTts"]),
|
| 229 |
+
"ttsWasCalled": False,
|
| 230 |
+
}
|
| 231 |
+
if include_speech_text:
|
| 232 |
+
result["speechText"] = speech_text
|
| 233 |
+
return result
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def main_cli() -> None:
|
| 237 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 238 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 239 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 240 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 241 |
+
|
| 242 |
+
parser = argparse.ArgumentParser(description="Dry-run Arabic PDF extraction without calling TTS.")
|
| 243 |
+
parser.add_argument("pdf", type=Path, help="Path to the PDF to test")
|
| 244 |
+
parser.add_argument(
|
| 245 |
+
"--chunk-size",
|
| 246 |
+
type=int,
|
| 247 |
+
default=main.CLOUD_TTS_MAX_CHARS,
|
| 248 |
+
help="Maximum characters per simulated TTS chunk",
|
| 249 |
+
)
|
| 250 |
+
parser.add_argument("--ocr-engine", choices=sorted(main.OCR_ENGINE_CHOICES), help="OCR engine to test.")
|
| 251 |
+
parser.add_argument("--ocr-render-zoom", help="Render zoom for Tesseract or shared OCR fallback.")
|
| 252 |
+
parser.add_argument("--easyocr-render-zoom", help="Render zoom for EasyOCR.")
|
| 253 |
+
parser.add_argument("--qari-ocr-render-zoom", help="Render zoom for QARI-OCR.")
|
| 254 |
+
parser.add_argument("--tawkeed-ocr-render-zoom", help="Render zoom for Tawkeed Arabic OCR.")
|
| 255 |
+
parser.add_argument("--katib-ocr-render-zoom", help="Render zoom for KATIB Arabic OCR.")
|
| 256 |
+
parser.add_argument("--arabic-qwen-ocr-render-zoom", help="Render zoom for Arabic-Qwen3.5 OCR.")
|
| 257 |
+
parser.add_argument("--arabic-glm-ocr-render-zoom", help="Render zoom for Arabic-GLM OCR.")
|
| 258 |
+
parser.add_argument("--baseer-ocr-render-zoom", help="Render zoom for Baseer Arabic OCR.")
|
| 259 |
+
parser.add_argument("--paddleocr-render-zoom", help="Render zoom for PaddleOCR.")
|
| 260 |
+
parser.add_argument("--paddleocr-vl-render-zoom", help="Render zoom for PaddleOCR-VL.")
|
| 261 |
+
parser.add_argument("--surya-render-zoom", help="Render zoom for Surya OCR.")
|
| 262 |
+
parser.add_argument("--tesseract-psm", help="Tesseract page segmentation mode, for example 4 or 6.")
|
| 263 |
+
parser.add_argument(
|
| 264 |
+
"--from-extraction",
|
| 265 |
+
help="Apply settings from a benchmark extraction label, for example best:tesseract@2x-psm4.",
|
| 266 |
+
)
|
| 267 |
+
parser.add_argument("--env-file", type=Path, help="Load OCR settings from a generated OCR .env snippet.")
|
| 268 |
+
parser.add_argument(
|
| 269 |
+
"--include-speech-text",
|
| 270 |
+
action="store_true",
|
| 271 |
+
help="Include the full cleaned speech text in JSON output.",
|
| 272 |
+
)
|
| 273 |
+
parser.add_argument(
|
| 274 |
+
"--speech-sample-chars",
|
| 275 |
+
type=int,
|
| 276 |
+
default=1200,
|
| 277 |
+
help="Maximum cleaned speech characters to include as speechSampleText. Use 0 for no limit.",
|
| 278 |
+
)
|
| 279 |
+
args = parser.parse_args()
|
| 280 |
+
result = dry_run_pdf(
|
| 281 |
+
args.pdf,
|
| 282 |
+
args.chunk_size,
|
| 283 |
+
ocr_engine=args.ocr_engine,
|
| 284 |
+
ocr_render_zoom=args.ocr_render_zoom,
|
| 285 |
+
easyocr_render_zoom=args.easyocr_render_zoom,
|
| 286 |
+
qari_ocr_render_zoom=args.qari_ocr_render_zoom,
|
| 287 |
+
tawkeed_ocr_render_zoom=args.tawkeed_ocr_render_zoom,
|
| 288 |
+
katib_ocr_render_zoom=args.katib_ocr_render_zoom,
|
| 289 |
+
arabic_qwen_ocr_render_zoom=args.arabic_qwen_ocr_render_zoom,
|
| 290 |
+
arabic_glm_ocr_render_zoom=args.arabic_glm_ocr_render_zoom,
|
| 291 |
+
baseer_ocr_render_zoom=args.baseer_ocr_render_zoom,
|
| 292 |
+
paddleocr_render_zoom=args.paddleocr_render_zoom,
|
| 293 |
+
paddleocr_vl_render_zoom=args.paddleocr_vl_render_zoom,
|
| 294 |
+
surya_render_zoom=args.surya_render_zoom,
|
| 295 |
+
tesseract_psm=args.tesseract_psm,
|
| 296 |
+
from_extraction=args.from_extraction,
|
| 297 |
+
env_file=args.env_file,
|
| 298 |
+
include_speech_text=args.include_speech_text,
|
| 299 |
+
speech_sample_chars=args.speech_sample_chars,
|
| 300 |
+
)
|
| 301 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 302 |
+
if not result["readyForTts"]:
|
| 303 |
+
raise SystemExit(1)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
if __name__ == "__main__":
|
| 307 |
+
main_cli()
|
scripts/easyocr_extract.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main() -> None:
|
| 9 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 10 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 11 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 12 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 13 |
+
|
| 14 |
+
parser = argparse.ArgumentParser(description="Extract Arabic text from page images with EasyOCR.")
|
| 15 |
+
parser.add_argument("--image-dir", required=True, type=Path)
|
| 16 |
+
parser.add_argument("--out", required=True, type=Path)
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
|
| 19 |
+
image_paths = sorted(args.image_dir.glob("*.png"))
|
| 20 |
+
total = max(len(image_paths), 1)
|
| 21 |
+
print(f"ARABIC_READER_PROGRESS 0 {total}", flush=True)
|
| 22 |
+
|
| 23 |
+
import easyocr
|
| 24 |
+
|
| 25 |
+
reader = easyocr.Reader(["ar"], gpu=False, verbose=False)
|
| 26 |
+
pieces: list[str] = []
|
| 27 |
+
for index, image_path in enumerate(image_paths, start=1):
|
| 28 |
+
lines = reader.readtext(str(image_path), detail=0, paragraph=True)
|
| 29 |
+
page_text = "\n".join(str(line).strip() for line in lines if str(line).strip())
|
| 30 |
+
if page_text:
|
| 31 |
+
pieces.append(page_text)
|
| 32 |
+
print(f"ARABIC_READER_PROGRESS {index} {total}", flush=True)
|
| 33 |
+
|
| 34 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
args.out.write_text("\n\n".join(pieces), encoding="utf-8")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
scripts/export_hf_space.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import shutil
|
| 7 |
+
import uuid
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Iterable
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 13 |
+
DEFAULT_OUTPUT = ROOT_DIR / "outputs" / "huggingface-space"
|
| 14 |
+
|
| 15 |
+
FILES = [
|
| 16 |
+
"requirements.txt",
|
| 17 |
+
"requirements-silma.txt",
|
| 18 |
+
"requirements-supertonic.txt",
|
| 19 |
+
"requirements-paddleocr.txt",
|
| 20 |
+
"requirements-paddleocr-vl.txt",
|
| 21 |
+
"requirements-qari-ocr.txt",
|
| 22 |
+
"requirements-tawkeed-ocr.txt",
|
| 23 |
+
"requirements-katib-ocr.txt",
|
| 24 |
+
"requirements-arabic-qwen-ocr.txt",
|
| 25 |
+
"requirements-arabic-glm-ocr.txt",
|
| 26 |
+
"requirements-baseer-ocr.txt",
|
| 27 |
+
]
|
| 28 |
+
DIRECTORIES = [
|
| 29 |
+
"app",
|
| 30 |
+
"api",
|
| 31 |
+
"docs",
|
| 32 |
+
"static",
|
| 33 |
+
"scripts",
|
| 34 |
+
]
|
| 35 |
+
EXCLUDE_NAMES = {
|
| 36 |
+
"__pycache__",
|
| 37 |
+
".pytest_cache",
|
| 38 |
+
".ruff_cache",
|
| 39 |
+
}
|
| 40 |
+
EXCLUDE_SUFFIXES = {
|
| 41 |
+
".pyc",
|
| 42 |
+
".pyo",
|
| 43 |
+
".pyd",
|
| 44 |
+
}
|
| 45 |
+
MANIFEST_NAME = ".export-manifest.json"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def should_copy(path: Path) -> bool:
|
| 49 |
+
if path.name in EXCLUDE_NAMES:
|
| 50 |
+
return False
|
| 51 |
+
if path.suffix in EXCLUDE_SUFFIXES:
|
| 52 |
+
return False
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def copy_tree(source: Path, destination: Path) -> None:
|
| 57 |
+
if destination.exists():
|
| 58 |
+
shutil.rmtree(destination)
|
| 59 |
+
shutil.copytree(source, destination, ignore=lambda _dir, names: [name for name in names if not should_copy(Path(name))])
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def iter_manifest_source_files(root: Path | None = None) -> Iterable[Path]:
|
| 63 |
+
root = root or ROOT_DIR
|
| 64 |
+
for relative in FILES:
|
| 65 |
+
path = root / relative
|
| 66 |
+
if path.exists():
|
| 67 |
+
yield path
|
| 68 |
+
dockerfile = root / "Dockerfile.worker"
|
| 69 |
+
if dockerfile.exists():
|
| 70 |
+
yield dockerfile
|
| 71 |
+
for relative in DIRECTORIES:
|
| 72 |
+
base = root / relative
|
| 73 |
+
if not base.exists():
|
| 74 |
+
continue
|
| 75 |
+
for path in sorted(base.rglob("*")):
|
| 76 |
+
if path.is_file() and should_copy(path):
|
| 77 |
+
yield path
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def file_sha256(path: Path) -> str:
|
| 81 |
+
digest = hashlib.sha256()
|
| 82 |
+
with path.open("rb") as handle:
|
| 83 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 84 |
+
digest.update(chunk)
|
| 85 |
+
return digest.hexdigest()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def build_export_manifest(root: Path | None = None) -> dict[str, object]:
|
| 89 |
+
root = root or ROOT_DIR
|
| 90 |
+
files: dict[str, str] = {}
|
| 91 |
+
for path in iter_manifest_source_files(root):
|
| 92 |
+
relative = path.relative_to(root).as_posix()
|
| 93 |
+
if relative == "Dockerfile.worker":
|
| 94 |
+
relative = "Dockerfile"
|
| 95 |
+
files[relative] = file_sha256(path)
|
| 96 |
+
return {
|
| 97 |
+
"version": 1,
|
| 98 |
+
"source": "ArabicTranslator",
|
| 99 |
+
"files": files,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_hf_space_bundle(output_dir: Path) -> list[str]:
|
| 104 |
+
output_dir.mkdir(parents=True)
|
| 105 |
+
copied: list[str] = []
|
| 106 |
+
for relative in FILES:
|
| 107 |
+
source = ROOT_DIR / relative
|
| 108 |
+
destination = output_dir / relative
|
| 109 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
shutil.copy2(source, destination)
|
| 111 |
+
copied.append(relative)
|
| 112 |
+
|
| 113 |
+
shutil.copy2(ROOT_DIR / "Dockerfile.worker", output_dir / "Dockerfile")
|
| 114 |
+
copied.append("Dockerfile")
|
| 115 |
+
|
| 116 |
+
for relative in DIRECTORIES:
|
| 117 |
+
source = ROOT_DIR / relative
|
| 118 |
+
destination = output_dir / relative
|
| 119 |
+
copy_tree(source, destination)
|
| 120 |
+
copied.append(relative)
|
| 121 |
+
|
| 122 |
+
(output_dir / ".dockerignore").write_text(
|
| 123 |
+
"\n".join(
|
| 124 |
+
[
|
| 125 |
+
".git",
|
| 126 |
+
".env",
|
| 127 |
+
".venv",
|
| 128 |
+
".venv-*",
|
| 129 |
+
"__pycache__",
|
| 130 |
+
".pytest_cache",
|
| 131 |
+
"outputs",
|
| 132 |
+
"uploads",
|
| 133 |
+
"data",
|
| 134 |
+
"test_pdfs",
|
| 135 |
+
"tests",
|
| 136 |
+
"*.pyc",
|
| 137 |
+
"*.pyo",
|
| 138 |
+
"*.pyd",
|
| 139 |
+
"*.log",
|
| 140 |
+
"",
|
| 141 |
+
]
|
| 142 |
+
),
|
| 143 |
+
encoding="utf-8",
|
| 144 |
+
)
|
| 145 |
+
copied.append(".dockerignore")
|
| 146 |
+
|
| 147 |
+
write_space_readme(output_dir / "README.md")
|
| 148 |
+
copied.append("README.md")
|
| 149 |
+
(output_dir / MANIFEST_NAME).write_text(
|
| 150 |
+
json.dumps(build_export_manifest(), indent=2, sort_keys=True) + "\n",
|
| 151 |
+
encoding="utf-8",
|
| 152 |
+
)
|
| 153 |
+
copied.append(MANIFEST_NAME)
|
| 154 |
+
(output_dir / ".export-complete").write_text("ready\n", encoding="utf-8")
|
| 155 |
+
copied.append(".export-complete")
|
| 156 |
+
return copied
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def export_hf_space(output_dir: Path = DEFAULT_OUTPUT, force: bool = False) -> dict[str, object]:
|
| 160 |
+
output_dir = output_dir.resolve()
|
| 161 |
+
if output_dir.exists() and not force:
|
| 162 |
+
raise FileExistsError(f"{output_dir} already exists. Use --force to replace it.")
|
| 163 |
+
|
| 164 |
+
parent = output_dir.parent
|
| 165 |
+
parent.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
staging_dir = parent / f".{output_dir.name}.staging-{uuid.uuid4().hex}"
|
| 167 |
+
backup_dir = parent / f".{output_dir.name}.previous-{uuid.uuid4().hex}"
|
| 168 |
+
copied: list[str] = []
|
| 169 |
+
try:
|
| 170 |
+
copied = build_hf_space_bundle(staging_dir)
|
| 171 |
+
issues = validate_export(staging_dir)
|
| 172 |
+
if issues:
|
| 173 |
+
raise ValueError(f"Staged Hugging Face Space bundle is invalid: {', '.join(issues)}")
|
| 174 |
+
if output_dir.exists():
|
| 175 |
+
output_dir.rename(backup_dir)
|
| 176 |
+
staging_dir.rename(output_dir)
|
| 177 |
+
except Exception:
|
| 178 |
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
| 179 |
+
if backup_dir.exists() and not output_dir.exists():
|
| 180 |
+
backup_dir.rename(output_dir)
|
| 181 |
+
raise
|
| 182 |
+
finally:
|
| 183 |
+
shutil.rmtree(backup_dir, ignore_errors=True)
|
| 184 |
+
return {"outputDir": str(output_dir), "copied": copied}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def write_space_readme(path: Path) -> None:
|
| 188 |
+
path.write_text(
|
| 189 |
+
"""# Arabic Audio Reader Worker
|
| 190 |
+
|
| 191 |
+
This is the Docker worker bundle for the Arabic PDF Reader.
|
| 192 |
+
|
| 193 |
+
## Hugging Face Space Settings
|
| 194 |
+
|
| 195 |
+
- SDK: Docker
|
| 196 |
+
- Hardware: free CPU is acceptable for demos, but cold starts and long books can be slow
|
| 197 |
+
- Free CPU Basic currently provides 2 vCPU, 16 GB RAM, and 50 GB non-persistent disk by default; treat generated audio as short-lived unless you add persistent/object storage
|
| 198 |
+
- Port: 7860
|
| 199 |
+
- Default build: installs SILMA, PaddleOCR Arabic, Tesseract Arabic, and eSpeak NG
|
| 200 |
+
- Optional fast CPU voice: set Docker build arg `INSTALL_SUPERTONIC=1` to add Supertonic 3 Arabic-capable local TTS
|
| 201 |
+
- Stronger OCR build: set Docker build arg `INSTALL_TAWKEED_OCR=1`, `INSTALL_KATIB_OCR=1`, `INSTALL_ARABIC_QWEN_OCR=1`, `INSTALL_ARABIC_GLM_OCR=1`, or `INSTALL_BASEER_OCR=1` for Arabic-trained models, or `INSTALL_QARI_OCR=1` for the heavier Arabic-book model
|
| 202 |
+
|
| 203 |
+
Set these Space secrets:
|
| 204 |
+
|
| 205 |
+
```text
|
| 206 |
+
ACCESS_CODE=1234
|
| 207 |
+
SECRET_KEY=<generated by outputs\\deployment-handoff.md>
|
| 208 |
+
CORS_ORIGINS=https://your-vercel-app.vercel.app
|
| 209 |
+
COOKIE_SAMESITE=none
|
| 210 |
+
COOKIE_SECURE=1
|
| 211 |
+
OCR_ENGINE=arabic-max
|
| 212 |
+
DEFAULT_VOICE_ID=silma-local
|
| 213 |
+
OUTPUT_RETENTION_DAYS=7
|
| 214 |
+
OUTPUT_MAX_FILES=25
|
| 215 |
+
AUDIO_FORMAT=mp3
|
| 216 |
+
MP3_BITRATE=96k
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
Generate the deployment handoff from the main repo to get the exact `SECRET_KEY`, worker secrets, Vercel environment variables, and final proof command:
|
| 220 |
+
|
| 221 |
+
```powershell
|
| 222 |
+
python scripts\\deployment_handoff.py https://your-space.hf.space --origin https://your-vercel-app.vercel.app --code 1234
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
Keep `outputs\\deployment-handoff.md` private because it contains deployment secrets.
|
| 226 |
+
|
| 227 |
+
The compact process recommendation is included at `docs/recommended-free-stack.md`, with the machine-readable deployment decision card at `docs/recommended-decision-card.json` and its readable companion at `docs/recommended-decision-card.md`. The current practical default is PyMuPDF embedded text first, `OCR_ENGINE=arabic-max` for scanned Arabic pages, SILMA TTS for the first clean voice, and downloadable worker audio.
|
| 228 |
+
|
| 229 |
+
Optional stronger-worker build args:
|
| 230 |
+
|
| 231 |
+
```text
|
| 232 |
+
INSTALL_QARI_OCR=1
|
| 233 |
+
INSTALL_TAWKEED_OCR=1
|
| 234 |
+
INSTALL_KATIB_OCR=1
|
| 235 |
+
INSTALL_ARABIC_QWEN_OCR=1
|
| 236 |
+
INSTALL_ARABIC_GLM_OCR=1
|
| 237 |
+
INSTALL_BASEER_OCR=1
|
| 238 |
+
INSTALL_PADDLEOCR_VL=1
|
| 239 |
+
INSTALL_SUPERTONIC=1
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
Use `INSTALL_TAWKEED_OCR=1`, `INSTALL_KATIB_OCR=1`, `INSTALL_ARABIC_QWEN_OCR=1`, `INSTALL_ARABIC_GLM_OCR=1`, or `INSTALL_BASEER_OCR=1` first when you want an Arabic-trained OCR model. Use `INSTALL_QARI_OCR=1` when you want the strongest Arabic-book OCR and the worker has enough memory/GPU. Leave heavy options at `0` on free CPU Spaces unless a short benchmark proves the stronger model is worth the cold start, build time, memory, and runtime.
|
| 243 |
+
|
| 244 |
+
After the Space builds, verify it from your main repo:
|
| 245 |
+
|
| 246 |
+
```powershell
|
| 247 |
+
python scripts\\verify_worker.py https://your-space.hf.space --code 1234 --origin https://your-vercel-app.vercel.app --require-cors --smoke-upload --smoke-scanned --smoke-ocr-engine arabic-max
|
| 248 |
+
```
|
| 249 |
+
""",
|
| 250 |
+
encoding="utf-8",
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def validate_export(output_dir: Path) -> list[str]:
|
| 255 |
+
required = [
|
| 256 |
+
"Dockerfile",
|
| 257 |
+
"README.md",
|
| 258 |
+
".dockerignore",
|
| 259 |
+
MANIFEST_NAME,
|
| 260 |
+
"requirements.txt",
|
| 261 |
+
"requirements-silma.txt",
|
| 262 |
+
"requirements-supertonic.txt",
|
| 263 |
+
"requirements-paddleocr.txt",
|
| 264 |
+
"requirements-paddleocr-vl.txt",
|
| 265 |
+
"requirements-qari-ocr.txt",
|
| 266 |
+
"requirements-tawkeed-ocr.txt",
|
| 267 |
+
"requirements-katib-ocr.txt",
|
| 268 |
+
"requirements-arabic-qwen-ocr.txt",
|
| 269 |
+
"requirements-arabic-glm-ocr.txt",
|
| 270 |
+
"requirements-baseer-ocr.txt",
|
| 271 |
+
".export-complete",
|
| 272 |
+
"app/main.py",
|
| 273 |
+
"api/index.py",
|
| 274 |
+
"static/index.html",
|
| 275 |
+
"scripts/setup_silma.sh",
|
| 276 |
+
"scripts/setup_supertonic.sh",
|
| 277 |
+
"scripts/setup_paddleocr.sh",
|
| 278 |
+
"scripts/setup_paddleocr_vl.sh",
|
| 279 |
+
"scripts/setup_qari_ocr.sh",
|
| 280 |
+
"scripts/setup_tawkeed_ocr.sh",
|
| 281 |
+
"scripts/setup_katib_ocr.sh",
|
| 282 |
+
"scripts/setup_arabic_qwen_ocr.sh",
|
| 283 |
+
"scripts/setup_arabic_glm_ocr.sh",
|
| 284 |
+
"scripts/setup_baseer_ocr.sh",
|
| 285 |
+
"scripts/qari_ocr_extract.py",
|
| 286 |
+
"scripts/tawkeed_ocr_extract.py",
|
| 287 |
+
"scripts/katib_ocr_extract.py",
|
| 288 |
+
"scripts/arabic_qwen_ocr_extract.py",
|
| 289 |
+
"scripts/arabic_glm_ocr_extract.py",
|
| 290 |
+
"scripts/baseer_ocr_extract.py",
|
| 291 |
+
"scripts/configure_vercel_worker.py",
|
| 292 |
+
"scripts/deploy_hf_space.py",
|
| 293 |
+
"scripts/finish_live_deployment.py",
|
| 294 |
+
"scripts/prepare_live_deployment.py",
|
| 295 |
+
"scripts/validate_deployment_env.py",
|
| 296 |
+
"scripts/refresh_research_evidence.py",
|
| 297 |
+
"scripts/score_voice_listening.py",
|
| 298 |
+
"scripts/score_tts_preprocessor.py",
|
| 299 |
+
"docs/recommended-free-stack.md",
|
| 300 |
+
"docs/recommended-decision-card.md",
|
| 301 |
+
"docs/recommended-decision-card.json",
|
| 302 |
+
]
|
| 303 |
+
missing = [relative for relative in required if not (output_dir / relative).exists()]
|
| 304 |
+
forbidden = [".env", "uploads", "outputs", "data", "test_pdfs", ".venv", ".venv-silma", ".venv-ocr"]
|
| 305 |
+
present_forbidden = [relative for relative in forbidden if (output_dir / relative).exists()]
|
| 306 |
+
return [f"missing:{item}" for item in missing] + [f"forbidden:{item}" for item in present_forbidden]
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def main() -> None:
|
| 310 |
+
parser = argparse.ArgumentParser(description="Export a clean Hugging Face Spaces Docker worker bundle.")
|
| 311 |
+
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="Destination folder for the Space bundle.")
|
| 312 |
+
parser.add_argument("--force", action="store_true", help="Replace the destination folder if it already exists.")
|
| 313 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact summary.")
|
| 314 |
+
args = parser.parse_args()
|
| 315 |
+
|
| 316 |
+
result = export_hf_space(args.out, force=args.force)
|
| 317 |
+
issues = validate_export(args.out)
|
| 318 |
+
result["ready"] = not issues
|
| 319 |
+
result["issues"] = issues
|
| 320 |
+
if args.json:
|
| 321 |
+
print(json.dumps(result, indent=2))
|
| 322 |
+
else:
|
| 323 |
+
print(f"Exported Hugging Face Space bundle to {result['outputDir']}")
|
| 324 |
+
if issues:
|
| 325 |
+
print("Issues:")
|
| 326 |
+
for issue in issues:
|
| 327 |
+
print(f"- {issue}")
|
| 328 |
+
else:
|
| 329 |
+
print("Bundle is ready to push to a Docker Space.")
|
| 330 |
+
if issues:
|
| 331 |
+
raise SystemExit(1)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
main()
|
scripts/export_ocr_sample_images.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from dataclasses import asdict
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import fitz
|
| 11 |
+
|
| 12 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 13 |
+
if str(ROOT_DIR) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 15 |
+
|
| 16 |
+
from scripts.select_test_pages import PageScore, select_pages
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
DEFAULT_OUT_DIR = ROOT_DIR / "outputs" / "external-ocr-sample"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def quote_path(path: Path) -> str:
|
| 23 |
+
text = str(path)
|
| 24 |
+
if any(char.isspace() for char in text):
|
| 25 |
+
return f'"{text}"'
|
| 26 |
+
return text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def render_page_images(pdf_path: Path, selected: list[PageScore], out_dir: Path, zoom: float = 2.0) -> list[dict[str, Any]]:
|
| 30 |
+
if zoom <= 0:
|
| 31 |
+
raise ValueError("zoom must be greater than 0")
|
| 32 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
images: list[dict[str, Any]] = []
|
| 34 |
+
with fitz.open(pdf_path) as document:
|
| 35 |
+
for item in selected:
|
| 36 |
+
page = document[item.page - 1]
|
| 37 |
+
pixmap = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
|
| 38 |
+
image_path = out_dir / f"page-{item.page:04d}.png"
|
| 39 |
+
pixmap.save(image_path)
|
| 40 |
+
images.append(
|
| 41 |
+
{
|
| 42 |
+
"page": item.page,
|
| 43 |
+
"path": str(image_path),
|
| 44 |
+
"width": pixmap.width,
|
| 45 |
+
"height": pixmap.height,
|
| 46 |
+
"score": item.score,
|
| 47 |
+
"characters": item.characters,
|
| 48 |
+
"arabicWords": item.arabic_words,
|
| 49 |
+
"inkRatio": item.ink_ratio,
|
| 50 |
+
}
|
| 51 |
+
)
|
| 52 |
+
return images
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_external_ocr_commands(image_dir: Path) -> dict[str, str]:
|
| 56 |
+
image_glob = quote_path(image_dir / "page-*.png")
|
| 57 |
+
return {
|
| 58 |
+
"wiredOcrBenchmark": (
|
| 59 |
+
"python scripts\\benchmark_ocr.py C:\\path\\to\\book-best-5-pages.pdf "
|
| 60 |
+
"--page-limit 5 --engines arabic-max arabic tawkeed-ocr baseer-ocr arabic-glm-ocr arabic-qwen-ocr katib-ocr qari-ocr paddleocr tesseract"
|
| 61 |
+
),
|
| 62 |
+
"arabicGlmExternal": (
|
| 63 |
+
f"If the Arabic-GLM sidecar is not installed, run Arabic-GLM-OCR-v2 externally against {image_glob}, "
|
| 64 |
+
"then compare its cleaned Arabic text against QARI/KATIB and the wired OCR benchmark."
|
| 65 |
+
),
|
| 66 |
+
"arabicQwen35External": (
|
| 67 |
+
f"If the Arabic-Qwen sidecar is not installed, run Arabic-Qwen3.5-OCR-v4 externally against {image_glob}, "
|
| 68 |
+
"then compare printed, handwritten, and diacritic-heavy Arabic output against the wired OCR benchmark."
|
| 69 |
+
),
|
| 70 |
+
"loayQwen25External": (
|
| 71 |
+
f"Run loay/Arabic-OCR-Qwen2.5-VL-7B-Vision externally against {image_glob} only on a strong worker, "
|
| 72 |
+
"then compare its Arabic OCR output against QARI, KATIB, Arabic-Qwen3.5, Baseer, and the wired OCR benchmark."
|
| 73 |
+
),
|
| 74 |
+
"dimiArabicOcrExternal": (
|
| 75 |
+
f"Run DIMI Arabic OCR v2 externally against {image_glob} only on a strong worker, then compare printed Arabic, "
|
| 76 |
+
"diacritics-heavy text, and formatting preservation against the wired OCR benchmark."
|
| 77 |
+
),
|
| 78 |
+
"baseerExternal": (
|
| 79 |
+
f"If the Baseer sidecar is not installed, run Baseer OCR externally against {image_glob}, then compare "
|
| 80 |
+
"complex-layout Arabic output against the wired OCR benchmark."
|
| 81 |
+
),
|
| 82 |
+
"atlasOcrExternal": (
|
| 83 |
+
f"Run AtlasOCR externally against {image_glob} only for Darija/Moroccan Arabic PDFs, then compare "
|
| 84 |
+
"against the wired OCR benchmark and confirm licensing before production wiring."
|
| 85 |
+
),
|
| 86 |
+
"ketabaExternal": (
|
| 87 |
+
f"Run Ketaba-OCR LoRA externally against {image_glob}, then compare its cleaned Arabic text "
|
| 88 |
+
"against the wired OCR benchmark before adding a sidecar."
|
| 89 |
+
),
|
| 90 |
+
"oiOcrExternal": (
|
| 91 |
+
f"Run oi-OCR externally against {image_glob}, then compare structured Markdown/text extraction, "
|
| 92 |
+
"Arabic reading order, and speech-readiness against the wired OCR benchmark."
|
| 93 |
+
),
|
| 94 |
+
"nuExtract3External": (
|
| 95 |
+
f"Run numind/NuExtract3 externally in document-to-Markdown or content mode against {image_glob}, "
|
| 96 |
+
"then compare Arabic text preservation, layout cleanup, tables/forms, and speech-readiness against the wired OCR benchmark."
|
| 97 |
+
),
|
| 98 |
+
"chandraExternal": (
|
| 99 |
+
f"Run Chandra OCR 2 externally against {image_glob} for complex layouts, tables, forms, or mixed-language pages, "
|
| 100 |
+
"then compare Arabic reading order and speech-readiness against the wired Arabic OCR benchmark before considering any hosted use."
|
| 101 |
+
),
|
| 102 |
+
"dotsOcrExternal": (
|
| 103 |
+
f"Run rednote-hilab/dots.ocr externally against {image_glob} for document layout, reading order, tables, formulas, "
|
| 104 |
+
"or mixed-language pages, then compare Arabic word preservation and speech-readiness against the wired Arabic OCR benchmark."
|
| 105 |
+
),
|
| 106 |
+
"olmocrArabicLoraExternal": (
|
| 107 |
+
f"Run hastyle/olmOCR-arabic-lora-v2 externally against {image_glob} only for full-page Arabic manuscript scans "
|
| 108 |
+
"on a large worker; compare it against Ketaba, QARI, line-cropped HAFITH/Glimpse, and the wired OCR benchmark."
|
| 109 |
+
),
|
| 110 |
+
"arabicLargeNougatExternal": (
|
| 111 |
+
f"Run MohamedRashad/arabic-large-nougat externally against {image_glob} for Arabic book-page OCR-to-Markdown, "
|
| 112 |
+
"then compare text preservation, reading order, hallucination risk, and speech-readiness against the wired OCR benchmark."
|
| 113 |
+
),
|
| 114 |
+
"doctrArabicExternal": (
|
| 115 |
+
f"Run the DocTR Arabic FAST detector plus Arabic PARSEQ recognizer externally against {image_glob}, "
|
| 116 |
+
"then compare classic OCR text ordering, Arabic word preservation, and recognizer license fit before any wiring."
|
| 117 |
+
),
|
| 118 |
+
"krakenExternal": (
|
| 119 |
+
f"Run Kraken/eScriptorium externally against {image_glob} with an Arabic-script recognition model or "
|
| 120 |
+
"line-cropped workflow when pages look like historical print/manuscripts; then compare Arabic word "
|
| 121 |
+
"preservation and reading order against the wired OCR benchmark before any sidecar work."
|
| 122 |
+
),
|
| 123 |
+
"glmDocsExternal": (
|
| 124 |
+
f"Run maloukafer/GLM-OCR-finetuned-documents externally against {image_glob} only for form-like, "
|
| 125 |
+
"administrative, newspaper, or official-document PDFs; compare it against Arabic-GLM-OCR-v2 and the wired benchmark."
|
| 126 |
+
),
|
| 127 |
+
"mimohaOcrExternal": (
|
| 128 |
+
f"Run mimoha/ocr externally against {image_glob} only as a low-priority sparse-card check, then compare "
|
| 129 |
+
"the resulting Arabic text with the same speech-readiness score."
|
| 130 |
+
),
|
| 131 |
+
"handwritten4bitExternal": (
|
| 132 |
+
f"Run sherif1313/Arabic-handwritten-OCR-4bit-Qwen2.5-VL-3B-v3 externally against {image_glob} when the "
|
| 133 |
+
"PDF has handwriting or manuscript pages, then compare the smaller 4-bit output against handwritten-v3 and the wired OCR benchmark."
|
| 134 |
+
),
|
| 135 |
+
"nakbaManuscriptLineExternal": (
|
| 136 |
+
f"Run U4RASD/ar-ms-baseline externally only on line-cropped manuscript images from {image_glob}; keep it "
|
| 137 |
+
"as a NAKBA 2026 manuscript-line benchmark unless a separate layout step crops pages into text lines."
|
| 138 |
+
),
|
| 139 |
+
"hafithExternal": (
|
| 140 |
+
f"Run mdnaseif/hafith externally only after cropping {image_glob} into text-line images; use it for "
|
| 141 |
+
"historical Arabic manuscript or archival-print pages, then merge line outputs before scoring speech-readiness."
|
| 142 |
+
),
|
| 143 |
+
"glimpseRtlExternal": (
|
| 144 |
+
f"Run surfiniaburger/unsloth_finetune_ocr_arabic externally only after cropping {image_glob} into "
|
| 145 |
+
"Arabic/Persian text-line images; compare the merged RTL line text against HAFITH, NAKBA line OCR, and the wired benchmark."
|
| 146 |
+
),
|
| 147 |
+
"qwen25GgufExternal": (
|
| 148 |
+
f"Run mo1998/arabic-ocr-qwen2.5-vl externally against {image_glob} as a QariOCR-trained GGUF/Unsloth "
|
| 149 |
+
"benchmark, then compare scanned-book, religious-text, handwriting, and mixed Arabic-English output against QARI 0.4 and the wired OCR benchmark."
|
| 150 |
+
),
|
| 151 |
+
"tawkeedExternal": (
|
| 152 |
+
f"If the Tawkeed sidecar is not installed, run Tawkeed OCR externally against {image_glob}, then compare "
|
| 153 |
+
"Arabic document, handwriting, and scene-text output against QARI 0.4, KATIB, Arabic-Qwen, Baseer, and the wired OCR benchmark."
|
| 154 |
+
),
|
| 155 |
+
"falconExternal": (
|
| 156 |
+
f"Run Falcon-OCR externally against {image_glob}, then compare Arabic word count, reading order, "
|
| 157 |
+
"and speech-readiness against KATIB/QARI/PaddleOCR."
|
| 158 |
+
),
|
| 159 |
+
"scoreExternalText": (
|
| 160 |
+
"python scripts\\score_external_ocr.py "
|
| 161 |
+
"--candidate arabic-glm=outputs\\external-ocr-sample\\arabic-glm.txt "
|
| 162 |
+
"--candidate arabic-qwen35=outputs\\external-ocr-sample\\arabic-qwen35.txt "
|
| 163 |
+
"--candidate loay-qwen25=outputs\\external-ocr-sample\\loay-qwen25.txt "
|
| 164 |
+
"--candidate dimi-v2=outputs\\external-ocr-sample\\dimi-v2.txt "
|
| 165 |
+
"--candidate atlasocr=outputs\\external-ocr-sample\\atlasocr.txt "
|
| 166 |
+
"--candidate ketaba=outputs\\external-ocr-sample\\ketaba.txt "
|
| 167 |
+
"--candidate oi-ocr=outputs\\external-ocr-sample\\oi-ocr.txt "
|
| 168 |
+
"--candidate nuextract3=outputs\\external-ocr-sample\\nuextract3.txt "
|
| 169 |
+
"--candidate chandra=outputs\\external-ocr-sample\\chandra.txt "
|
| 170 |
+
"--candidate dots-ocr=outputs\\external-ocr-sample\\dots-ocr.txt "
|
| 171 |
+
"--candidate olmocr-arabic-lora=outputs\\external-ocr-sample\\olmocr-arabic-lora.txt "
|
| 172 |
+
"--candidate arabic-large-nougat=outputs\\external-ocr-sample\\arabic-large-nougat.txt "
|
| 173 |
+
"--candidate doctr-arabic=outputs\\external-ocr-sample\\doctr-arabic.txt "
|
| 174 |
+
"--candidate kraken=outputs\\external-ocr-sample\\kraken.txt "
|
| 175 |
+
"--candidate glm-docs=outputs\\external-ocr-sample\\glm-docs.txt "
|
| 176 |
+
"--candidate mimoha-ocr=outputs\\external-ocr-sample\\mimoha-ocr.txt "
|
| 177 |
+
"--candidate handwritten-4bit=outputs\\external-ocr-sample\\handwritten-4bit.txt "
|
| 178 |
+
"--candidate nakba-ms-line=outputs\\external-ocr-sample\\nakba-ms-line.txt "
|
| 179 |
+
"--candidate hafith=outputs\\external-ocr-sample\\hafith.txt "
|
| 180 |
+
"--candidate glimpse-rtl=outputs\\external-ocr-sample\\glimpse-rtl.txt "
|
| 181 |
+
"--candidate qwen25-gguf=outputs\\external-ocr-sample\\qwen25-gguf.txt "
|
| 182 |
+
"--candidate tawkeed=outputs\\external-ocr-sample\\tawkeed.txt "
|
| 183 |
+
"--candidate falcon=outputs\\external-ocr-sample\\falcon.txt "
|
| 184 |
+
"--candidate baseer=outputs\\external-ocr-sample\\baseer.txt "
|
| 185 |
+
"--baseline-json outputs\\external-ocr-sample\\wired-ocr-baseline.json "
|
| 186 |
+
"--write-report outputs\\external-ocr-sample\\external-ocr-score.md "
|
| 187 |
+
"--write-json outputs\\external-ocr-sample\\external-ocr-score.json"
|
| 188 |
+
),
|
| 189 |
+
"promotionGate": (
|
| 190 |
+
"python scripts\\model_promotion_gate.py "
|
| 191 |
+
"--candidate-name \"External OCR winner\" --kind ocr --license Apache-2.0 "
|
| 192 |
+
"--score-json outputs\\external-ocr-sample\\external-ocr-score.json "
|
| 193 |
+
"--same-sample --runtime-ok --privacy-ok --human-reviewed "
|
| 194 |
+
"--write-report outputs\\external-ocr-sample\\model-promotion-gate.md"
|
| 195 |
+
),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def write_ocr_sample_report(path: Path, result: dict[str, Any]) -> None:
|
| 200 |
+
commands = result["commands"]
|
| 201 |
+
lines = [
|
| 202 |
+
"# External Arabic OCR Sample",
|
| 203 |
+
"",
|
| 204 |
+
f"PDF: {result['pdf']}",
|
| 205 |
+
f"Image directory: {result['imageDir']}",
|
| 206 |
+
f"Pages: {', '.join(str(image['page']) for image in result['images'])}",
|
| 207 |
+
f"Render zoom: {result['zoom']}",
|
| 208 |
+
"",
|
| 209 |
+
"Use these exact page images for every external OCR model. Do not compare models on different pages or different render scales.",
|
| 210 |
+
"",
|
| 211 |
+
"## Images",
|
| 212 |
+
"",
|
| 213 |
+
"| Page | PNG | Size | Score | Arabic Words | Ink Ratio |",
|
| 214 |
+
"| --- | --- | --- | --- | --- | --- |",
|
| 215 |
+
]
|
| 216 |
+
for image in result["images"]:
|
| 217 |
+
lines.append(
|
| 218 |
+
f"| {image['page']} | {image['path']} | {image['width']}x{image['height']} | "
|
| 219 |
+
f"{image['score']} | {image['arabicWords']} | {image['inkRatio']} |"
|
| 220 |
+
)
|
| 221 |
+
lines.extend(
|
| 222 |
+
[
|
| 223 |
+
"",
|
| 224 |
+
"## Comparison Commands",
|
| 225 |
+
"",
|
| 226 |
+
"Wired OCR benchmark:",
|
| 227 |
+
"",
|
| 228 |
+
"```powershell",
|
| 229 |
+
commands["wiredOcrBenchmark"],
|
| 230 |
+
"```",
|
| 231 |
+
"",
|
| 232 |
+
"Arabic-GLM-OCR-v2:",
|
| 233 |
+
"",
|
| 234 |
+
"```text",
|
| 235 |
+
commands["arabicGlmExternal"],
|
| 236 |
+
"```",
|
| 237 |
+
"",
|
| 238 |
+
"Arabic-Qwen3.5-OCR-v4:",
|
| 239 |
+
"",
|
| 240 |
+
"```text",
|
| 241 |
+
commands["arabicQwen35External"],
|
| 242 |
+
"```",
|
| 243 |
+
"",
|
| 244 |
+
"Loay Arabic-OCR-Qwen2.5-VL-7B:",
|
| 245 |
+
"",
|
| 246 |
+
"```text",
|
| 247 |
+
commands["loayQwen25External"],
|
| 248 |
+
"```",
|
| 249 |
+
"",
|
| 250 |
+
"DIMI Arabic OCR v2:",
|
| 251 |
+
"",
|
| 252 |
+
"```text",
|
| 253 |
+
commands["dimiArabicOcrExternal"],
|
| 254 |
+
"```",
|
| 255 |
+
"",
|
| 256 |
+
"AtlasOCR:",
|
| 257 |
+
"",
|
| 258 |
+
"```text",
|
| 259 |
+
commands["atlasOcrExternal"],
|
| 260 |
+
"```",
|
| 261 |
+
"",
|
| 262 |
+
"Ketaba-OCR LoRA:",
|
| 263 |
+
"",
|
| 264 |
+
"```text",
|
| 265 |
+
commands["ketabaExternal"],
|
| 266 |
+
"```",
|
| 267 |
+
"",
|
| 268 |
+
"oi-OCR:",
|
| 269 |
+
"",
|
| 270 |
+
"```text",
|
| 271 |
+
commands["oiOcrExternal"],
|
| 272 |
+
"```",
|
| 273 |
+
"",
|
| 274 |
+
"NuExtract3:",
|
| 275 |
+
"",
|
| 276 |
+
"```text",
|
| 277 |
+
commands["nuExtract3External"],
|
| 278 |
+
"```",
|
| 279 |
+
"",
|
| 280 |
+
"Chandra OCR 2:",
|
| 281 |
+
"",
|
| 282 |
+
"```text",
|
| 283 |
+
commands["chandraExternal"],
|
| 284 |
+
"```",
|
| 285 |
+
"",
|
| 286 |
+
"dots.ocr:",
|
| 287 |
+
"",
|
| 288 |
+
"```text",
|
| 289 |
+
commands["dotsOcrExternal"],
|
| 290 |
+
"```",
|
| 291 |
+
"",
|
| 292 |
+
"olmOCR Arabic LoRA v2:",
|
| 293 |
+
"",
|
| 294 |
+
"```text",
|
| 295 |
+
commands["olmocrArabicLoraExternal"],
|
| 296 |
+
"```",
|
| 297 |
+
"",
|
| 298 |
+
"Arabic Large Nougat:",
|
| 299 |
+
"",
|
| 300 |
+
"```text",
|
| 301 |
+
commands["arabicLargeNougatExternal"],
|
| 302 |
+
"```",
|
| 303 |
+
"",
|
| 304 |
+
"DocTR Arabic FAST/PARSEQ:",
|
| 305 |
+
"",
|
| 306 |
+
"```text",
|
| 307 |
+
commands["doctrArabicExternal"],
|
| 308 |
+
"```",
|
| 309 |
+
"",
|
| 310 |
+
"Kraken/eScriptorium Arabic script:",
|
| 311 |
+
"",
|
| 312 |
+
"```text",
|
| 313 |
+
commands["krakenExternal"],
|
| 314 |
+
"```",
|
| 315 |
+
"",
|
| 316 |
+
"GLM-OCR Arabic/French documents:",
|
| 317 |
+
"",
|
| 318 |
+
"```text",
|
| 319 |
+
commands["glmDocsExternal"],
|
| 320 |
+
"```",
|
| 321 |
+
"",
|
| 322 |
+
"mimoha Arabic OCR:",
|
| 323 |
+
"",
|
| 324 |
+
"```text",
|
| 325 |
+
commands["mimohaOcrExternal"],
|
| 326 |
+
"```",
|
| 327 |
+
"",
|
| 328 |
+
"Arabic handwritten OCR 4-bit Qwen2.5-VL:",
|
| 329 |
+
"",
|
| 330 |
+
"```text",
|
| 331 |
+
commands["handwritten4bitExternal"],
|
| 332 |
+
"```",
|
| 333 |
+
"",
|
| 334 |
+
"NAKBA Arabic manuscript line OCR baseline:",
|
| 335 |
+
"",
|
| 336 |
+
"```text",
|
| 337 |
+
commands["nakbaManuscriptLineExternal"],
|
| 338 |
+
"```",
|
| 339 |
+
"",
|
| 340 |
+
"HAFITH:",
|
| 341 |
+
"",
|
| 342 |
+
"```text",
|
| 343 |
+
commands["hafithExternal"],
|
| 344 |
+
"```",
|
| 345 |
+
"",
|
| 346 |
+
"Glimpse RTL OCR:",
|
| 347 |
+
"",
|
| 348 |
+
"```text",
|
| 349 |
+
commands["glimpseRtlExternal"],
|
| 350 |
+
"```",
|
| 351 |
+
"",
|
| 352 |
+
"Arabic OCR Qwen2.5-VL GGUF:",
|
| 353 |
+
"",
|
| 354 |
+
"```text",
|
| 355 |
+
commands["qwen25GgufExternal"],
|
| 356 |
+
"```",
|
| 357 |
+
"",
|
| 358 |
+
"Tawkeed OCR:",
|
| 359 |
+
"",
|
| 360 |
+
"```text",
|
| 361 |
+
commands["tawkeedExternal"],
|
| 362 |
+
"```",
|
| 363 |
+
"",
|
| 364 |
+
"Falcon-OCR:",
|
| 365 |
+
"",
|
| 366 |
+
"```text",
|
| 367 |
+
commands["falconExternal"],
|
| 368 |
+
"```",
|
| 369 |
+
"",
|
| 370 |
+
"Baseer OCR:",
|
| 371 |
+
"",
|
| 372 |
+
"```text",
|
| 373 |
+
commands["baseerExternal"],
|
| 374 |
+
"```",
|
| 375 |
+
"",
|
| 376 |
+
"Score external OCR text outputs:",
|
| 377 |
+
"",
|
| 378 |
+
"```powershell",
|
| 379 |
+
commands["scoreExternalText"],
|
| 380 |
+
"```",
|
| 381 |
+
"",
|
| 382 |
+
"Promotion gate for the winning OCR candidate:",
|
| 383 |
+
"",
|
| 384 |
+
"```powershell",
|
| 385 |
+
commands["promotionGate"],
|
| 386 |
+
"```",
|
| 387 |
+
"",
|
| 388 |
+
"## Promotion Rule",
|
| 389 |
+
"",
|
| 390 |
+
"Replace the candidate name and license in the promotion-gate command with the real winning model. Promote an external OCR model only if it beats the wired Arabic OCR stack on these same pages, has an acceptable license, and the worker can handle its memory, cold start, and runtime.",
|
| 391 |
+
]
|
| 392 |
+
)
|
| 393 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 394 |
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def export_ocr_sample_images(
|
| 398 |
+
pdf_path: Path,
|
| 399 |
+
out_dir: Path = DEFAULT_OUT_DIR,
|
| 400 |
+
count: int = 5,
|
| 401 |
+
skip_first: int = 0,
|
| 402 |
+
zoom: float = 2.0,
|
| 403 |
+
) -> dict[str, Any]:
|
| 404 |
+
if not pdf_path.exists():
|
| 405 |
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
| 406 |
+
if pdf_path.suffix.lower() != ".pdf":
|
| 407 |
+
raise ValueError("Input must be a PDF file.")
|
| 408 |
+
if count < 1:
|
| 409 |
+
raise ValueError("count must be at least 1")
|
| 410 |
+
|
| 411 |
+
selected = select_pages(pdf_path, count=count, skip_first=skip_first)
|
| 412 |
+
image_dir = out_dir / "images"
|
| 413 |
+
images = render_page_images(pdf_path, selected, image_dir, zoom=zoom)
|
| 414 |
+
commands = build_external_ocr_commands(image_dir)
|
| 415 |
+
result = {
|
| 416 |
+
"pdf": str(pdf_path),
|
| 417 |
+
"imageDir": str(image_dir),
|
| 418 |
+
"reportPath": str(out_dir / "external-ocr-sample.md"),
|
| 419 |
+
"zoom": zoom,
|
| 420 |
+
"pages": [item.page for item in selected],
|
| 421 |
+
"scores": [asdict(item) for item in selected],
|
| 422 |
+
"images": images,
|
| 423 |
+
"commands": commands,
|
| 424 |
+
}
|
| 425 |
+
write_ocr_sample_report(Path(result["reportPath"]), result)
|
| 426 |
+
return result
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def main_cli() -> None:
|
| 430 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 431 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 432 |
+
parser = argparse.ArgumentParser(description="Export selected Arabic PDF page images for external OCR benchmarking.")
|
| 433 |
+
parser.add_argument("pdf", type=Path, help="Source Arabic PDF")
|
| 434 |
+
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, help="Output directory")
|
| 435 |
+
parser.add_argument("--count", type=int, default=5, help="Number of pages to export")
|
| 436 |
+
parser.add_argument("--skip-first", type=int, default=0, help="Ignore the first N pages before scoring")
|
| 437 |
+
parser.add_argument("--zoom", type=float, default=2.0, help="Render zoom for PNG images")
|
| 438 |
+
parser.add_argument("--json", action="store_true", help="Print JSON details")
|
| 439 |
+
args = parser.parse_args()
|
| 440 |
+
|
| 441 |
+
result = export_ocr_sample_images(
|
| 442 |
+
args.pdf,
|
| 443 |
+
out_dir=args.out_dir,
|
| 444 |
+
count=args.count,
|
| 445 |
+
skip_first=args.skip_first,
|
| 446 |
+
zoom=args.zoom,
|
| 447 |
+
)
|
| 448 |
+
if args.json:
|
| 449 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 450 |
+
else:
|
| 451 |
+
print(f"Wrote OCR image sample report: {result['reportPath']}")
|
| 452 |
+
print(f"Rendered pages: {', '.join(str(page) for page in result['pages'])}")
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
if __name__ == "__main__":
|
| 456 |
+
main_cli()
|
scripts/export_tts_sample.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 10 |
+
if str(ROOT_DIR) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 12 |
+
|
| 13 |
+
from app import main
|
| 14 |
+
from scripts.dry_run_pdf import dry_run_pdf
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def safe_command_path(path: Path) -> str:
|
| 18 |
+
text = str(path)
|
| 19 |
+
return f'"{text}"' if " " in text else text
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_external_commands(text_path: Path, output_dir: Path) -> dict[str, str]:
|
| 23 |
+
text_arg = safe_command_path(text_path)
|
| 24 |
+
output_arg = safe_command_path(output_dir)
|
| 25 |
+
return {
|
| 26 |
+
"localVoiceBenchmark": (
|
| 27 |
+
f"python scripts\\benchmark_voices.py --text-file {text_arg} "
|
| 28 |
+
f"--out-dir {output_arg}\\local-voices --write-report {output_arg}\\local-voices.md"
|
| 29 |
+
),
|
| 30 |
+
"mossTtsNanoOnnx": (
|
| 31 |
+
"moss-tts-nano generate --backend onnx --language ar "
|
| 32 |
+
f"--text-file {text_arg} --prompt-speech C:\\path\\to\\arabic-reference.wav"
|
| 33 |
+
),
|
| 34 |
+
"mossTtsNanoServer": "moss-tts-nano serve --backend onnx",
|
| 35 |
+
"supertonicLocal": (
|
| 36 |
+
f"python scripts\\benchmark_voices.py --voices supertonic-ar --text-file {text_arg} "
|
| 37 |
+
f"--out-dir {output_arg}\\supertonic --write-report {output_arg}\\supertonic.md"
|
| 38 |
+
),
|
| 39 |
+
"mishkalaTashkeelExternal": (
|
| 40 |
+
"Diacritize the same cleaned sample with flokymind/mishkala and save it beside the plain sample, "
|
| 41 |
+
f"for example {output_arg}\\arabic-tts-sample-mishkala.txt. Then synthesize both files with the same voice."
|
| 42 |
+
),
|
| 43 |
+
"mishkalaVoiceBenchmark": (
|
| 44 |
+
f"python scripts\\benchmark_voices.py --text-file {output_arg}\\arabic-tts-sample-mishkala.txt "
|
| 45 |
+
f"--out-dir {output_arg}\\mishkala-local-voices --write-report {output_arg}\\mishkala-local-voices.md"
|
| 46 |
+
),
|
| 47 |
+
"tashkeel350External": (
|
| 48 |
+
"Diacritize the same cleaned sample with Etherll/Tashkeel-350M and save it beside the plain sample, "
|
| 49 |
+
f"for example {output_arg}\\arabic-tts-sample-tashkeel350.txt. Then synthesize plain, Mishkala, "
|
| 50 |
+
"and Tashkeel-350M samples with the same voice before choosing a preprocessor."
|
| 51 |
+
),
|
| 52 |
+
"tashkeel350VoiceBenchmark": (
|
| 53 |
+
f"python scripts\\benchmark_voices.py --text-file {output_arg}\\arabic-tts-sample-tashkeel350.txt "
|
| 54 |
+
f"--out-dir {output_arg}\\tashkeel350-local-voices --write-report {output_arg}\\tashkeel350-local-voices.md"
|
| 55 |
+
),
|
| 56 |
+
"preprocessorListeningScore": (
|
| 57 |
+
"python scripts\\score_tts_preprocessor.py "
|
| 58 |
+
"--rating plain=4,5,5,4,4 --rating mishkala=5,4,4,4,4 --rating tashkeel350=5,4,4,4,4 "
|
| 59 |
+
f"--write-report {output_arg}\\tts-preprocessor-score.md "
|
| 60 |
+
f"--write-json {output_arg}\\tts-preprocessor-score.json"
|
| 61 |
+
),
|
| 62 |
+
"voiceListeningScore": (
|
| 63 |
+
"python scripts\\score_voice_listening.py "
|
| 64 |
+
"--rating silma-local=5,4,4,5,5 --rating espeak-ar-clear=3,2,4,3,5 "
|
| 65 |
+
f"--write-report {output_arg}\\voice-listening-score.md "
|
| 66 |
+
f"--write-json {output_arg}\\voice-listening-score.json"
|
| 67 |
+
),
|
| 68 |
+
"voicePromotionGate": (
|
| 69 |
+
"python scripts\\model_promotion_gate.py "
|
| 70 |
+
"--candidate-name \"External voice winner\" --kind tts --license Apache-2.0 "
|
| 71 |
+
f"--score-json {output_arg}\\voice-listening-score.json "
|
| 72 |
+
"--same-sample --runtime-ok --privacy-ok --human-reviewed "
|
| 73 |
+
f"--write-report {output_arg}\\voice-promotion-gate.md"
|
| 74 |
+
),
|
| 75 |
+
"preprocessorPromotionGate": (
|
| 76 |
+
"python scripts\\model_promotion_gate.py "
|
| 77 |
+
"--candidate-name \"TTS preprocessor winner\" --kind preprocessor --license Apache-2.0 "
|
| 78 |
+
f"--score-json {output_arg}\\tts-preprocessor-score.json "
|
| 79 |
+
"--same-sample --runtime-ok --privacy-ok --human-reviewed "
|
| 80 |
+
f"--write-report {output_arg}\\preprocessor-promotion-gate.md"
|
| 81 |
+
),
|
| 82 |
+
"omniVoiceExternal": (
|
| 83 |
+
"python -m omnivoice.cli "
|
| 84 |
+
f"--model k2-fsa/OmniVoice --language ar --text-file {text_arg} "
|
| 85 |
+
"--ref-audio C:\\path\\to\\arabic-reference.wav --ref-text \"Arabic reference transcript\""
|
| 86 |
+
),
|
| 87 |
+
"omniVoiceArabicLoraExternal": (
|
| 88 |
+
"Run OmniVoice with the Arabic LoRA adapter vivooglobal/omnivoice-lora-ar on the same text and "
|
| 89 |
+
"reference audio after the base OmniVoice benchmark works."
|
| 90 |
+
),
|
| 91 |
+
"tadaExternal": (
|
| 92 |
+
"Benchmark HumeAI/tada-3b-ml externally with language=\"ar\" on the same cleaned sample "
|
| 93 |
+
f"{text_arg}. It is designed to reduce off-script speech, but it reports the Llama 3.2 "
|
| 94 |
+
"license and is a 3B-class strong-worker path, so keep it outside the permissive default."
|
| 95 |
+
),
|
| 96 |
+
"lahgtnaChatterboxExternal": (
|
| 97 |
+
"python -m chatterbox.tts "
|
| 98 |
+
f"--model oddadmix/lahgtna-chatterbox-v1 --text-file {text_arg} "
|
| 99 |
+
"--audio-prompt-path C:\\path\\to\\arabic-reference.wav --repetition-penalty 1.25"
|
| 100 |
+
),
|
| 101 |
+
"namaaSaudiTtsExternal": (
|
| 102 |
+
"Use ChatterboxMultilingualTTS with the NAMAA-Space/NAMAA-Saudi-TTS safetensors on "
|
| 103 |
+
f"{text_arg}, language_id=\"ar\", and the same optional reference audio. Benchmark only for "
|
| 104 |
+
"Saudi/Gulf dialect fit, then compare against SILMA, Habibi, Saudi Arabic Qwen3-TTS, and Emirati voices."
|
| 105 |
+
),
|
| 106 |
+
"saudiChatterboxFineTuneExternal": (
|
| 107 |
+
"Use ChatterboxMultilingualTTS with FatimahEmadEldin/saudi-tts-chatterbox-finetuned T3 weights on "
|
| 108 |
+
f"{text_arg}, language_id=\"ar\", and the same optional reference audio. Benchmark only for "
|
| 109 |
+
"Saudi/Gulf dialect fit, then compare against NAMAA-Saudi-TTS, SILMA, Habibi, Saudi Arabic Qwen3-TTS, "
|
| 110 |
+
"and Emirati voices."
|
| 111 |
+
),
|
| 112 |
+
"nileTtsExternal": (
|
| 113 |
+
"Benchmark KickItLikeShika/NileTTS-XTTS only for Egyptian/dialectal Arabic using this same cleaned "
|
| 114 |
+
f"sample {text_arg}. It is Apache-2.0, but not an MSA book default."
|
| 115 |
+
),
|
| 116 |
+
"chatterboxMultilingualExternal": (
|
| 117 |
+
"Use ChatterboxMultilingualTTS.from_pretrained(...).generate(text, language_id=\"ar\", "
|
| 118 |
+
"audio_prompt_path=\"C:\\path\\to\\arabic-reference.wav\") against "
|
| 119 |
+
f"{text_arg}; compare pacing and pronunciation against SILMA and Habibi."
|
| 120 |
+
),
|
| 121 |
+
"chatterboxMultilingualOnnxExternal": (
|
| 122 |
+
"Benchmark onnx-community/chatterbox-multilingual-ONNX with language_id=\"ar\" against "
|
| 123 |
+
f"{text_arg}; compare CPU/ONNX runtime, repetition, pacing, and pronunciation against SILMA, "
|
| 124 |
+
"Habibi, and the regular Chatterbox-Multilingual path."
|
| 125 |
+
),
|
| 126 |
+
"ttsArabicOnnxExternal": (
|
| 127 |
+
"Benchmark nipponjo/tts-arabic-onnx with the same cleaned sample "
|
| 128 |
+
f"{text_arg}; try FastPitch, MixerTTS, speaker IDs, pace, and vowelizer options, then compare "
|
| 129 |
+
"CPU runtime and pronunciation against SILMA, Supertonic, MOSS-TTS-Nano, and Chatterbox ONNX. "
|
| 130 |
+
"Confirm model/repo licensing before production use."
|
| 131 |
+
),
|
| 132 |
+
"sparkTtsArabicExternal": (
|
| 133 |
+
"Spark-TTS Arabic requires the Spark-TTS repo plus diacritized Arabic/reference audio; benchmark it "
|
| 134 |
+
f"externally with {text_arg} only after preparing that reference workflow."
|
| 135 |
+
),
|
| 136 |
+
"sofeliaTtsExternal": (
|
| 137 |
+
"Sofelia-TTS is a Palestinian Arabic/MiraTTS voice-cloning path; benchmark it only for dialectal text "
|
| 138 |
+
f"using the same sample {text_arg}."
|
| 139 |
+
),
|
| 140 |
+
"arabicF5TtsCaution": (
|
| 141 |
+
"Arabic-F5-TTS-v2 is non-commercial and requires fully diacritized Arabic; keep it to personal "
|
| 142 |
+
"experiments unless that license and input requirement are acceptable."
|
| 143 |
+
),
|
| 144 |
+
"threeArabTtsExternal": (
|
| 145 |
+
"Benchmark sherif1313/3arab-TTS-500M-v1 and the VoiceDesign variant on this same cleaned Arabic "
|
| 146 |
+
f"sample: {text_arg}. It is Apache-2.0 and Arabic-only, but new enough that listenability and "
|
| 147 |
+
"long-form stability need manual checks before app wiring."
|
| 148 |
+
),
|
| 149 |
+
"voxcpm2External": f"Use {text_arg} as the exact Arabic text sample when testing VoxCPM2 externally.",
|
| 150 |
+
"voxtralTtsCaution": (
|
| 151 |
+
"Voxtral TTS supports Arabic on its model card, but it is CC-BY-NC-4.0 and GPU-heavy; "
|
| 152 |
+
f"use {text_arg} only for personal/non-commercial strong-worker listening comparisons."
|
| 153 |
+
),
|
| 154 |
+
"qwen3TtsCaution": (
|
| 155 |
+
"Do not promote Qwen3-TTS for this Arabic reader until an official Arabic-capable checkpoint "
|
| 156 |
+
"or Arabic fine-tune is verified on this same sample."
|
| 157 |
+
),
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def write_markdown_report(path: Path, result: dict[str, Any]) -> None:
|
| 162 |
+
commands = result["commands"]
|
| 163 |
+
lines = [
|
| 164 |
+
"# External Arabic TTS Sample",
|
| 165 |
+
"",
|
| 166 |
+
f"PDF: `{result.get('pdf', '-')}`",
|
| 167 |
+
f"Text file: `{result['textPath']}`",
|
| 168 |
+
f"Characters: {result['characters']}",
|
| 169 |
+
f"Arabic words: {result['arabicWords']}",
|
| 170 |
+
f"OCR extraction: `{result.get('extraction', '-')}`",
|
| 171 |
+
f"Quality: `{result['quality']}`",
|
| 172 |
+
"",
|
| 173 |
+
"Use this same cleaned Arabic text for every voice/model comparison. Do not compare voices with different OCR text.",
|
| 174 |
+
"",
|
| 175 |
+
"## Commands",
|
| 176 |
+
"",
|
| 177 |
+
"Local installed voices:",
|
| 178 |
+
"",
|
| 179 |
+
f"```powershell\n{commands['localVoiceBenchmark']}\n```",
|
| 180 |
+
"",
|
| 181 |
+
"MOSS-TTS-Nano ONNX external benchmark:",
|
| 182 |
+
"",
|
| 183 |
+
f"```powershell\n{commands['mossTtsNanoOnnx']}\n```",
|
| 184 |
+
"",
|
| 185 |
+
"MOSS-TTS-Nano local server:",
|
| 186 |
+
"",
|
| 187 |
+
f"```powershell\n{commands['mossTtsNanoServer']}\n```",
|
| 188 |
+
"",
|
| 189 |
+
"Supertonic 3 local CPU benchmark:",
|
| 190 |
+
"",
|
| 191 |
+
f"```powershell\n{commands['supertonicLocal']}\n```",
|
| 192 |
+
"",
|
| 193 |
+
"Mishkala Tashkeel pronunciation preprocessor:",
|
| 194 |
+
"",
|
| 195 |
+
f"```text\n{commands['mishkalaTashkeelExternal']}\n```",
|
| 196 |
+
"",
|
| 197 |
+
"Mishkala local voice benchmark:",
|
| 198 |
+
"",
|
| 199 |
+
f"```powershell\n{commands['mishkalaVoiceBenchmark']}\n```",
|
| 200 |
+
"",
|
| 201 |
+
"Tashkeel-350M pronunciation preprocessor:",
|
| 202 |
+
"",
|
| 203 |
+
f"```text\n{commands['tashkeel350External']}\n```",
|
| 204 |
+
"",
|
| 205 |
+
"Tashkeel-350M local voice benchmark:",
|
| 206 |
+
"",
|
| 207 |
+
f"```powershell\n{commands['tashkeel350VoiceBenchmark']}\n```",
|
| 208 |
+
"",
|
| 209 |
+
"Plain vs Mishkala vs Tashkeel-350M listening score:",
|
| 210 |
+
"",
|
| 211 |
+
f"```powershell\n{commands['preprocessorListeningScore']}\n```",
|
| 212 |
+
"",
|
| 213 |
+
"Preprocessor promotion gate:",
|
| 214 |
+
"",
|
| 215 |
+
f"```powershell\n{commands['preprocessorPromotionGate']}\n```",
|
| 216 |
+
"",
|
| 217 |
+
"Voice listening score:",
|
| 218 |
+
"",
|
| 219 |
+
f"```powershell\n{commands['voiceListeningScore']}\n```",
|
| 220 |
+
"",
|
| 221 |
+
"Voice promotion gate:",
|
| 222 |
+
"",
|
| 223 |
+
f"```powershell\n{commands['voicePromotionGate']}\n```",
|
| 224 |
+
"",
|
| 225 |
+
"OmniVoice external benchmark:",
|
| 226 |
+
"",
|
| 227 |
+
f"```powershell\n{commands['omniVoiceExternal']}\n```",
|
| 228 |
+
"",
|
| 229 |
+
"OmniVoice Arabic LoRA external benchmark:",
|
| 230 |
+
"",
|
| 231 |
+
f"```text\n{commands['omniVoiceArabicLoraExternal']}\n```",
|
| 232 |
+
"",
|
| 233 |
+
"TADA multilingual external benchmark:",
|
| 234 |
+
"",
|
| 235 |
+
f"```text\n{commands['tadaExternal']}\n```",
|
| 236 |
+
"",
|
| 237 |
+
"Lahgtna Chatterbox external benchmark:",
|
| 238 |
+
"",
|
| 239 |
+
f"```powershell\n{commands['lahgtnaChatterboxExternal']}\n```",
|
| 240 |
+
"",
|
| 241 |
+
"NAMAA-Saudi-TTS external benchmark:",
|
| 242 |
+
"",
|
| 243 |
+
f"```text\n{commands['namaaSaudiTtsExternal']}\n```",
|
| 244 |
+
"",
|
| 245 |
+
"Saudi Chatterbox fine-tune external benchmark:",
|
| 246 |
+
"",
|
| 247 |
+
f"```text\n{commands['saudiChatterboxFineTuneExternal']}\n```",
|
| 248 |
+
"",
|
| 249 |
+
"NileTTS-XTTS Egyptian Arabic benchmark:",
|
| 250 |
+
"",
|
| 251 |
+
f"```text\n{commands['nileTtsExternal']}\n```",
|
| 252 |
+
"",
|
| 253 |
+
"Chatterbox-Multilingual external benchmark:",
|
| 254 |
+
"",
|
| 255 |
+
f"```text\n{commands['chatterboxMultilingualExternal']}\n```",
|
| 256 |
+
"",
|
| 257 |
+
"Chatterbox-Multilingual ONNX external benchmark:",
|
| 258 |
+
"",
|
| 259 |
+
f"```text\n{commands['chatterboxMultilingualOnnxExternal']}\n```",
|
| 260 |
+
"",
|
| 261 |
+
"tts-arabic-onnx external benchmark:",
|
| 262 |
+
"",
|
| 263 |
+
f"```text\n{commands['ttsArabicOnnxExternal']}\n```",
|
| 264 |
+
"",
|
| 265 |
+
"Spark-TTS Arabic external benchmark:",
|
| 266 |
+
"",
|
| 267 |
+
f"```text\n{commands['sparkTtsArabicExternal']}\n```",
|
| 268 |
+
"",
|
| 269 |
+
"Sofelia-TTS external benchmark:",
|
| 270 |
+
"",
|
| 271 |
+
f"```text\n{commands['sofeliaTtsExternal']}\n```",
|
| 272 |
+
"",
|
| 273 |
+
"Arabic-F5-TTS-v2 caution:",
|
| 274 |
+
"",
|
| 275 |
+
f"```text\n{commands['arabicF5TtsCaution']}\n```",
|
| 276 |
+
"",
|
| 277 |
+
"3arab-TTS 500M external benchmark:",
|
| 278 |
+
"",
|
| 279 |
+
f"```text\n{commands['threeArabTtsExternal']}\n```",
|
| 280 |
+
"",
|
| 281 |
+
"VoxCPM2 external benchmark:",
|
| 282 |
+
"",
|
| 283 |
+
f"```text\n{commands['voxcpm2External']}\n```",
|
| 284 |
+
"",
|
| 285 |
+
"Voxtral TTS caution:",
|
| 286 |
+
"",
|
| 287 |
+
f"```text\n{commands['voxtralTtsCaution']}\n```",
|
| 288 |
+
"",
|
| 289 |
+
"Qwen3-TTS caution:",
|
| 290 |
+
"",
|
| 291 |
+
f"```text\n{commands['qwen3TtsCaution']}\n```",
|
| 292 |
+
"",
|
| 293 |
+
"## Listening Checklist",
|
| 294 |
+
"",
|
| 295 |
+
"- Arabic pronunciation is clear and not robotic.",
|
| 296 |
+
"- Pauses are comfortable for long book passages.",
|
| 297 |
+
"- Numbers, Quranic symbols, and punctuation are not read strangely.",
|
| 298 |
+
"- Runtime is acceptable before processing a full book.",
|
| 299 |
+
"- Replace placeholder candidate names and licenses in promotion-gate commands before changing the production default.",
|
| 300 |
+
]
|
| 301 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 302 |
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def export_tts_sample(
|
| 306 |
+
pdf_path: Path,
|
| 307 |
+
out_dir: Path = ROOT_DIR / "outputs" / "external-tts-sample",
|
| 308 |
+
max_chars: int = 1200,
|
| 309 |
+
chunk_size: int = main.CLOUD_TTS_MAX_CHARS,
|
| 310 |
+
ocr_engine: str | None = None,
|
| 311 |
+
from_extraction: str | None = None,
|
| 312 |
+
env_file: Path | None = None,
|
| 313 |
+
write_report: bool = True,
|
| 314 |
+
) -> dict[str, Any]:
|
| 315 |
+
dry_run = dry_run_pdf(
|
| 316 |
+
pdf_path,
|
| 317 |
+
chunk_size=chunk_size,
|
| 318 |
+
ocr_engine=ocr_engine,
|
| 319 |
+
from_extraction=from_extraction,
|
| 320 |
+
env_file=env_file,
|
| 321 |
+
include_speech_text=True,
|
| 322 |
+
speech_sample_chars=max_chars,
|
| 323 |
+
)
|
| 324 |
+
if not dry_run["readyForTts"]:
|
| 325 |
+
reasons = "; ".join(str(reason) for reason in dry_run.get("qualityReasons", []))
|
| 326 |
+
raise RuntimeError(f"OCR text is not ready for TTS. {reasons}".strip())
|
| 327 |
+
|
| 328 |
+
sample_text = str(dry_run["speechSampleText"]).strip()
|
| 329 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 330 |
+
text_path = out_dir / "arabic-tts-sample.txt"
|
| 331 |
+
text_path.write_text(sample_text + "\n", encoding="utf-8")
|
| 332 |
+
commands = build_external_commands(text_path, out_dir)
|
| 333 |
+
result: dict[str, Any] = {
|
| 334 |
+
"ready": True,
|
| 335 |
+
"pdf": str(pdf_path),
|
| 336 |
+
"textPath": str(text_path),
|
| 337 |
+
"reportPath": str(out_dir / "external-tts-sample.md"),
|
| 338 |
+
"characters": len(sample_text),
|
| 339 |
+
"fullSpeechCharacters": dry_run["speechCharacters"],
|
| 340 |
+
"arabicWords": dry_run["arabicWords"],
|
| 341 |
+
"quality": dry_run["quality"],
|
| 342 |
+
"qualityScore": dry_run["qualityScore"],
|
| 343 |
+
"qualityReasons": dry_run["qualityReasons"],
|
| 344 |
+
"ocrEngine": dry_run["ocrEngine"],
|
| 345 |
+
"extraction": dry_run["extraction"],
|
| 346 |
+
"commands": commands,
|
| 347 |
+
}
|
| 348 |
+
if write_report:
|
| 349 |
+
write_markdown_report(out_dir / "external-tts-sample.md", result)
|
| 350 |
+
return result
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def main_cli() -> None:
|
| 354 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 355 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 356 |
+
parser = argparse.ArgumentParser(description="Export the same cleaned Arabic text sample for external TTS benchmarking.")
|
| 357 |
+
parser.add_argument("pdf", type=Path, help="Arabic PDF to extract a cleaned speech sample from.")
|
| 358 |
+
parser.add_argument("--out-dir", type=Path, default=ROOT_DIR / "outputs" / "external-tts-sample")
|
| 359 |
+
parser.add_argument("--max-chars", type=int, default=1200, help="Maximum cleaned characters to export.")
|
| 360 |
+
parser.add_argument("--chunk-size", type=int, default=main.CLOUD_TTS_MAX_CHARS)
|
| 361 |
+
parser.add_argument("--ocr-engine", choices=sorted(main.OCR_ENGINE_CHOICES), help="OCR engine to test.")
|
| 362 |
+
parser.add_argument("--from-extraction", help="Apply settings from a benchmark extraction label.")
|
| 363 |
+
parser.add_argument("--env-file", type=Path, help="Load OCR settings from a generated OCR .env snippet.")
|
| 364 |
+
parser.add_argument("--no-report", action="store_true", help="Only write the text file.")
|
| 365 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact summary.")
|
| 366 |
+
args = parser.parse_args()
|
| 367 |
+
|
| 368 |
+
result = export_tts_sample(
|
| 369 |
+
args.pdf,
|
| 370 |
+
out_dir=args.out_dir,
|
| 371 |
+
max_chars=args.max_chars,
|
| 372 |
+
chunk_size=args.chunk_size,
|
| 373 |
+
ocr_engine=args.ocr_engine,
|
| 374 |
+
from_extraction=args.from_extraction,
|
| 375 |
+
env_file=args.env_file,
|
| 376 |
+
write_report=not args.no_report,
|
| 377 |
+
)
|
| 378 |
+
if args.json:
|
| 379 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 380 |
+
else:
|
| 381 |
+
print(f"Wrote Arabic TTS sample: {result['textPath']}")
|
| 382 |
+
if not args.no_report:
|
| 383 |
+
print(f"Wrote benchmark handoff: {result['reportPath']}")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
if __name__ == "__main__":
|
| 387 |
+
main_cli()
|
scripts/finish_live_deployment.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 10 |
+
if str(ROOT_DIR) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 12 |
+
|
| 13 |
+
from scripts.configure_vercel_worker import DEFAULT_SITE_URL, configure_vercel_worker
|
| 14 |
+
from scripts.deploy_hf_space import DEFAULT_BUNDLE_DIR, deploy_hf_space, token_available, worker_url_for_repo
|
| 15 |
+
from scripts.prove_live_deployment import prove_live_deployment
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def finish_live_deployment(
|
| 19 |
+
repo_id: str,
|
| 20 |
+
site_url: str = DEFAULT_SITE_URL,
|
| 21 |
+
code: str = "1234",
|
| 22 |
+
bundle_dir: Path = DEFAULT_BUNDLE_DIR,
|
| 23 |
+
token: str | None = None,
|
| 24 |
+
private: bool = False,
|
| 25 |
+
force_export: bool = True,
|
| 26 |
+
verify_site_after_vercel: bool = True,
|
| 27 |
+
run_proof: bool = True,
|
| 28 |
+
smoke_ocr_engine: str = "arabic-max",
|
| 29 |
+
smoke_voice_id: str = "espeak-ar-clear",
|
| 30 |
+
smoke_timeout: float = 180,
|
| 31 |
+
) -> dict[str, object]:
|
| 32 |
+
deploy_result = deploy_hf_space(
|
| 33 |
+
repo_id=repo_id,
|
| 34 |
+
bundle_dir=bundle_dir,
|
| 35 |
+
token=token,
|
| 36 |
+
private=private,
|
| 37 |
+
force_export=force_export,
|
| 38 |
+
)
|
| 39 |
+
worker_url = str(deploy_result["workerUrl"])
|
| 40 |
+
vercel_result = configure_vercel_worker(
|
| 41 |
+
worker_url,
|
| 42 |
+
site_url=site_url,
|
| 43 |
+
code=code,
|
| 44 |
+
redeploy=True,
|
| 45 |
+
verify=verify_site_after_vercel,
|
| 46 |
+
)
|
| 47 |
+
proof_result = None
|
| 48 |
+
if run_proof:
|
| 49 |
+
proof_result = prove_live_deployment(
|
| 50 |
+
worker_url,
|
| 51 |
+
site_url,
|
| 52 |
+
code=code,
|
| 53 |
+
smoke_voice_id=smoke_voice_id,
|
| 54 |
+
smoke_ocr_engine=smoke_ocr_engine,
|
| 55 |
+
smoke_timeout=smoke_timeout,
|
| 56 |
+
)
|
| 57 |
+
return {
|
| 58 |
+
"repoId": repo_id,
|
| 59 |
+
"siteUrl": site_url.rstrip("/"),
|
| 60 |
+
"workerUrl": worker_url,
|
| 61 |
+
"workerDeploy": deploy_result,
|
| 62 |
+
"vercel": vercel_result,
|
| 63 |
+
"proof": proof_result,
|
| 64 |
+
"complete": bool(proof_result and proof_result.get("complete")),
|
| 65 |
+
"nextCommand": (
|
| 66 |
+
f"python scripts\\prove_live_deployment.py {worker_url} --origin {site_url.rstrip('/')} "
|
| 67 |
+
f"--code {code} --smoke-ocr-engine {smoke_ocr_engine}"
|
| 68 |
+
),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def dry_run_finish_live_deployment(
|
| 73 |
+
repo_id: str,
|
| 74 |
+
site_url: str = DEFAULT_SITE_URL,
|
| 75 |
+
code: str = "1234",
|
| 76 |
+
bundle_dir: Path = DEFAULT_BUNDLE_DIR,
|
| 77 |
+
token: str | None = None,
|
| 78 |
+
smoke_ocr_engine: str = "arabic-max",
|
| 79 |
+
) -> dict[str, object]:
|
| 80 |
+
worker_url = worker_url_for_repo(repo_id)
|
| 81 |
+
site_url = site_url.rstrip("/")
|
| 82 |
+
return {
|
| 83 |
+
"dryRun": True,
|
| 84 |
+
"repoId": repo_id,
|
| 85 |
+
"siteUrl": site_url,
|
| 86 |
+
"workerUrl": worker_url,
|
| 87 |
+
"bundleDir": str(bundle_dir),
|
| 88 |
+
"tokenAvailable": token_available(token),
|
| 89 |
+
"willUploadWorker": True,
|
| 90 |
+
"willConfigureVercel": True,
|
| 91 |
+
"willRunProof": True,
|
| 92 |
+
"commands": {
|
| 93 |
+
"setToken": '$env:HF_TOKEN="<your-hugging-face-write-token>"',
|
| 94 |
+
"finish": (
|
| 95 |
+
f"python scripts\\finish_live_deployment.py {repo_id} "
|
| 96 |
+
f"--site-url {site_url} --code {code} --smoke-ocr-engine {smoke_ocr_engine}"
|
| 97 |
+
),
|
| 98 |
+
"configureVercelOnly": (
|
| 99 |
+
f"python scripts\\configure_vercel_worker.py {worker_url} "
|
| 100 |
+
f"--site-url {site_url} --code {code} --verify"
|
| 101 |
+
),
|
| 102 |
+
"proofOnly": (
|
| 103 |
+
f"python scripts\\prove_live_deployment.py {worker_url} "
|
| 104 |
+
f"--origin {site_url} --code {code} --smoke-ocr-engine {smoke_ocr_engine}"
|
| 105 |
+
),
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main_with_args(argv: list[str] | None = None) -> int:
|
| 111 |
+
parser = argparse.ArgumentParser(
|
| 112 |
+
description="Upload the HF worker, configure Vercel WORKER_BASE_URL, redeploy, and run live proof."
|
| 113 |
+
)
|
| 114 |
+
parser.add_argument("repo_id", help="Hugging Face Space repo id, e.g. username/arabic-audio-reader-worker.")
|
| 115 |
+
parser.add_argument("--site-url", default=DEFAULT_SITE_URL)
|
| 116 |
+
parser.add_argument("--code", default="1234")
|
| 117 |
+
parser.add_argument("--bundle-dir", type=Path, default=DEFAULT_BUNDLE_DIR)
|
| 118 |
+
parser.add_argument("--token", help="Hugging Face write token. Prefer HF_TOKEN in the environment.")
|
| 119 |
+
parser.add_argument("--private", action="store_true")
|
| 120 |
+
parser.add_argument("--no-force-export", action="store_true")
|
| 121 |
+
parser.add_argument("--skip-site-verify", action="store_true")
|
| 122 |
+
parser.add_argument("--skip-proof", action="store_true")
|
| 123 |
+
parser.add_argument("--dry-run", action="store_true", help="Print the planned deployment without changing HF or Vercel.")
|
| 124 |
+
parser.add_argument("--smoke-ocr-engine", default="arabic-max")
|
| 125 |
+
parser.add_argument("--smoke-voice-id", default="espeak-ar-clear")
|
| 126 |
+
parser.add_argument("--smoke-timeout", type=float, default=180)
|
| 127 |
+
parser.add_argument("--json", action="store_true")
|
| 128 |
+
args = parser.parse_args(argv)
|
| 129 |
+
|
| 130 |
+
if args.dry_run:
|
| 131 |
+
result = dry_run_finish_live_deployment(
|
| 132 |
+
args.repo_id,
|
| 133 |
+
site_url=args.site_url,
|
| 134 |
+
code=args.code,
|
| 135 |
+
bundle_dir=args.bundle_dir,
|
| 136 |
+
token=args.token,
|
| 137 |
+
smoke_ocr_engine=args.smoke_ocr_engine,
|
| 138 |
+
)
|
| 139 |
+
if args.json:
|
| 140 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 141 |
+
else:
|
| 142 |
+
print(f"Predicted worker URL: {result['workerUrl']}")
|
| 143 |
+
print(f"Hugging Face token available: {'yes' if result['tokenAvailable'] else 'no'}")
|
| 144 |
+
print("Command:")
|
| 145 |
+
print(result["commands"]["finish"])
|
| 146 |
+
return 0
|
| 147 |
+
|
| 148 |
+
result = finish_live_deployment(
|
| 149 |
+
args.repo_id,
|
| 150 |
+
site_url=args.site_url,
|
| 151 |
+
code=args.code,
|
| 152 |
+
bundle_dir=args.bundle_dir,
|
| 153 |
+
token=args.token,
|
| 154 |
+
private=args.private,
|
| 155 |
+
force_export=not args.no_force_export,
|
| 156 |
+
verify_site_after_vercel=not args.skip_site_verify,
|
| 157 |
+
run_proof=not args.skip_proof,
|
| 158 |
+
smoke_ocr_engine=args.smoke_ocr_engine,
|
| 159 |
+
smoke_voice_id=args.smoke_voice_id,
|
| 160 |
+
smoke_timeout=args.smoke_timeout,
|
| 161 |
+
)
|
| 162 |
+
if args.json:
|
| 163 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 164 |
+
else:
|
| 165 |
+
print(f"Worker URL: {result['workerUrl']}")
|
| 166 |
+
print(f"Vercel site: {result['siteUrl']}")
|
| 167 |
+
if result["complete"]:
|
| 168 |
+
print("Live deployment proof is complete.")
|
| 169 |
+
else:
|
| 170 |
+
print("Live deployment still needs proof:")
|
| 171 |
+
print(result["nextCommand"])
|
| 172 |
+
return 0 if result["complete"] or args.skip_proof else 1
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def main() -> int:
|
| 176 |
+
return main_with_args()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
if __name__ == "__main__":
|
| 180 |
+
raise SystemExit(main())
|
scripts/hosted_preflight.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 9 |
+
if str(ROOT_DIR) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 11 |
+
|
| 12 |
+
from scripts.next_deployment_step import deployment_url_warnings
|
| 13 |
+
from scripts.verify_site import Check, print_table, verify_site
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
DEFAULT_OUT = ROOT_DIR / "outputs" / "hosted-preflight.json"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def next_action_for_checks(checks: list[Check], worker_url: str, site_url: str) -> str:
|
| 20 |
+
failed = {check.name: check.detail for check in checks if not check.ok}
|
| 21 |
+
if not failed:
|
| 22 |
+
return (
|
| 23 |
+
"Hosted route is ready. Upload a 5-page Arabic sample first, then run the full book only after "
|
| 24 |
+
"the OCR text and audio sample sound good."
|
| 25 |
+
)
|
| 26 |
+
if "site worker CORS ready" in failed:
|
| 27 |
+
return (
|
| 28 |
+
"Fix Hugging Face CORS before uploading: set CORS_ORIGINS to the exact Vercel URL, keep "
|
| 29 |
+
"COOKIE_SAMESITE=none and COOKIE_SECURE=1, restart the Space, redeploy Vercel, then rerun "
|
| 30 |
+
f"python scripts\\hosted_preflight.py {site_url} --worker-url {worker_url}."
|
| 31 |
+
)
|
| 32 |
+
if "site worker reachable from vercel" in failed or "site worker diagnostics endpoint" in failed:
|
| 33 |
+
return (
|
| 34 |
+
"Fix worker routing before uploading: confirm Vercel WORKER_BASE_URL is the public Hugging Face "
|
| 35 |
+
"Space URL, confirm the Space is awake/public, redeploy Vercel, then rerun hosted preflight."
|
| 36 |
+
)
|
| 37 |
+
if "site direct cloud fallback disabled" in failed:
|
| 38 |
+
return (
|
| 39 |
+
"Remove Vercel direct-cloud fallback variables before production: ENABLE_DIRECT_CLOUD_TTS, "
|
| 40 |
+
"HF_API_TOKEN, HF_TTS_MODEL, and DEFAULT_VOICE_ID. Keep WORKER_BASE_URL, then redeploy."
|
| 41 |
+
)
|
| 42 |
+
if "site login" in failed or "site session authenticated" in failed:
|
| 43 |
+
return "Fix ACCESS_CODE and SECRET_KEY matching between Vercel and the worker, then rerun hosted preflight."
|
| 44 |
+
return "Fix the failed Vercel site checks, redeploy, then rerun hosted preflight before uploading a PDF."
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_hosted_preflight(
|
| 48 |
+
site_url: str,
|
| 49 |
+
worker_url: str,
|
| 50 |
+
code: str = "1234",
|
| 51 |
+
out: Path = DEFAULT_OUT,
|
| 52 |
+
timeout: float = 60,
|
| 53 |
+
) -> dict[str, object]:
|
| 54 |
+
site_url = site_url.rstrip("/")
|
| 55 |
+
worker_url = worker_url.rstrip("/")
|
| 56 |
+
warnings = deployment_url_warnings(worker_url, site_url)
|
| 57 |
+
if warnings:
|
| 58 |
+
result: dict[str, object] = {
|
| 59 |
+
"readyToUpload": False,
|
| 60 |
+
"siteUrl": site_url,
|
| 61 |
+
"workerUrl": worker_url,
|
| 62 |
+
"urlWarnings": warnings,
|
| 63 |
+
"checks": [],
|
| 64 |
+
"nextAction": (
|
| 65 |
+
"Replace placeholder, local, non-HTTPS, or swapped URLs with the real Vercel production URL "
|
| 66 |
+
"and Hugging Face Space worker URL, then rerun hosted preflight."
|
| 67 |
+
),
|
| 68 |
+
}
|
| 69 |
+
else:
|
| 70 |
+
checks = verify_site(site_url, code, worker_url, timeout=timeout)
|
| 71 |
+
ready = all(check.ok for check in checks)
|
| 72 |
+
result = {
|
| 73 |
+
"readyToUpload": ready,
|
| 74 |
+
"siteUrl": site_url,
|
| 75 |
+
"workerUrl": worker_url,
|
| 76 |
+
"urlWarnings": [],
|
| 77 |
+
"checks": [check.__dict__ for check in checks],
|
| 78 |
+
"nextAction": next_action_for_checks(checks, worker_url, site_url),
|
| 79 |
+
}
|
| 80 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 82 |
+
result["report"] = str(out)
|
| 83 |
+
return result
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def print_result(result: dict[str, object]) -> None:
|
| 87 |
+
print(f"Hosted preflight: {'ready' if result['readyToUpload'] else 'not ready'}")
|
| 88 |
+
print(f"Site: {result['siteUrl']}")
|
| 89 |
+
print(f"Worker: {result['workerUrl']}")
|
| 90 |
+
warnings = result.get("urlWarnings") or []
|
| 91 |
+
if warnings:
|
| 92 |
+
print()
|
| 93 |
+
print("URL warnings")
|
| 94 |
+
for warning in warnings:
|
| 95 |
+
print(f"- {warning}")
|
| 96 |
+
checks = result.get("checks") or []
|
| 97 |
+
if checks:
|
| 98 |
+
print()
|
| 99 |
+
print_table([Check(str(item["name"]), bool(item["ok"]), str(item["detail"])) for item in checks])
|
| 100 |
+
print()
|
| 101 |
+
print(f"Next action: {result['nextAction']}")
|
| 102 |
+
print(f"Report: {result['report']}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def main() -> None:
|
| 106 |
+
parser = argparse.ArgumentParser(description="Run a quick hosted Vercel-to-worker preflight before uploading a large PDF.")
|
| 107 |
+
parser.add_argument("site_url", help="Vercel production URL, for example https://your-app.vercel.app")
|
| 108 |
+
parser.add_argument("--worker-url", required=True, help="Expected Hugging Face Space worker URL.")
|
| 109 |
+
parser.add_argument("--code", default="1234", help="Access code used by the site.")
|
| 110 |
+
parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="JSON report output path.")
|
| 111 |
+
parser.add_argument("--timeout", type=float, default=60, help="HTTP timeout in seconds.")
|
| 112 |
+
parser.add_argument("--json", action="store_true", help="Print JSON instead of a readable summary.")
|
| 113 |
+
args = parser.parse_args()
|
| 114 |
+
|
| 115 |
+
result = run_hosted_preflight(args.site_url, args.worker_url, code=args.code, out=args.out, timeout=args.timeout)
|
| 116 |
+
if args.json:
|
| 117 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 118 |
+
else:
|
| 119 |
+
print_result(result)
|
| 120 |
+
if not result["readyToUpload"]:
|
| 121 |
+
raise SystemExit(1)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|