# CODEX BRIEF — احتمالات القراءة المبنية على الصورة والسياق > مواصفة تنفيذية لنموذج Codex. الكاتب (المهندس) حدّد المنطق والعقود والمعايير؛ مهمتك كتابة الكود والتصميم وفقها. **لا تغيّر السلوك الافتراضي للتعرّف الحالي.** كل ميزة جديدة خلف علم تشغيل (flag) أو on-demand. ## 0. السياق (الوضع الحالي — لا تعِد بناءه) - التعرّف: `backend/app/services/ocr/kraken_service.py` → `extract_text_with_coordinates()` يُرجع قائمة أسطر: `{text, left, top, width, height, conf}`. يحتوي تكامل LM اختياري (`self.lm_decoder`) ويبني `lm_line_imgs` عبر `extract_polygons(bw_img, seg_res)` حين `USE_LM`. - فك الترميز: `backend/app/services/ocr/lm_decoder.py` → `LMDecoder`: - `decode_matrix(mat, idx2char)` يعمل beam search مع دمج LM (alpha,beta) ويُرجع **أفضل سلسلة واحدة** (بترتيب بصري visual). - `decode_line(model, pil_line_image, fallback)` يبني المصفوفة عبر `model.forward` ثم `decode_matrix` ثم يحوّل للمنطقي عبر `_to_logical` (python-bidi، base_dir='R'). - `beams` بنيته `{prefix: [pb, pnb]}` ودرجة السلسلة = `logaddexp(pb, pnb)`. - النموذج اللغوي الحرفي: `char_ngram_lm.py` → `CharNgramLM.load`, `.order`, `.logprob(ch, ctx)`. - القاموس الحالي: `backend/app/services/dictionary_service.py` → `MANUSCRIPT_VOCAB` (مجموعة يدوية ~250 كلمة) + `get_suggestions` بـ Levenshtein ≤2. **هذا هو سبب ضعف المرشّحات.** - endpoints التحليل: `backend/app/api/endpoints/analysis_api.py` (موجود، فيه `/analyze/word-candidates`). - الواجهة: `frontend/js/api.js` (نمط `_postJSON`), `frontend/js/app.js` (`currentData` = مصفوفة الأسطر، نافذة `#line-popup`, نافذة `#analysis-modal`). - مفتاح LLM: `settings.OPENROUTER_API_KEY` → `app.state.llm_service` (`LLMService._make_request`, `._extract_json`). - **بيئة التشغيل والاختبار للجزء B: Docker/Linux فقط** (يلزم kraken+torch+النموذج). الجزء A والـ JS يُختبران على ويندوز. --- ## الخطة A — قاموس حقيقي من بياناتنا (رخيصة، مستقلة، بلا تبعيات جديدة) **الهدف:** استبدال الـ250 كلمة اليدوية بمفردات واقعية + ترددات مبنية من نصوص GT (RASAM/TariMa، نفس المصدر الذي بُني منه `char8_trainval.lm`). ### A.1 سكربت بناء (offline، يُشغّل مرة) - ملف جديد: `backend/scripts/build_lexicon.py` - CLI: `python build_lexicon.py --corpus --out app/services/data/lexicon.json --min-count 2` - المنطق: 1. اقرأ كل نصوص GT (ابحث عن `.txt`/المدوّنة المستخدمة في `language_model/`؛ إن لم تجد، اقبل مساراً يدوياً عبر `--corpus`). 2. قسّم إلى كلمات: احتفظ بالرموز العربية `[؀-ۿ]+`، طول ≥2. 3. خزّن نسختين لكل كلمة: الأصلية + المطبّعة (إزالة تشكيل/توحيد همزات — أعد استخدام منطق `_norm` من `analysis_api.py`). 4. عُدّ الترددات؛ احذف ما تردده < `min_count`. 5. اكتب `lexicon.json`: `{"words": {"": freq, ...}, "total": N}` مرتّبة تنازلياً. 6. اطبع تقريراً: حجم المفردات، أعلى 20 كلمة. ### A.2 تعديل `dictionary_service.py` - حمّل `lexicon.json` **كسولاً** (مساره من `settings` أو افتراضي بجوار الخدمة)؛ إن غاب الملف → استمر بالـ`MANUSCRIPT_VOCAB` فقط (توافق خلفي، لا أعطال). - المفردات الفعّالة = `MANUSCRIPT_VOCAB` ∪ مفاتيح القاموس. - عدّل `get_suggestions(word, limit=3, context=None)`: - المرشّحون = كلمات بمسافة Levenshtein ≤2 (احسبها مقابل النسخة المطبّعة لتقليل التكلفة). - الترتيب = `(edit_distance تصاعدي, frequency تنازلي)`. - أبقِ التوقيع متوافقاً (المعاملات الإضافية اختيارية بقيم افتراضية). - أضِف `word_frequency(word) -> int` و حدّث `is_valid_word` ليشمل القاموس الجديد. ### A.3 معايير القبول (A) - وحدة اختبار: كلمة محرّفة قريبة من كلمة عالية التردد تُرجع التصحيح الصحيح أولاً. - لا أعطال عند غياب `lexicon.json`. - حجم المفردات الجديد ≥ بضعة آلاف (تقرير السكربت). --- ## الخطة B — مرشّحات قراءة مبنية على الصورة + ترجيح بالسياق (الجوهر) + تحسينات ### B.0 المبدأ المعماري (التزِم به) **beam يقترح مرشّحات من الحبر (الصورة) ← LLM/char-LM يرجّحها بالسياق ← القاموس (الخطة A) احتياط.** ⚠️ **LLM لا يخترع قراءات** غير مدعومة بالصورة؛ دوره **الترجيح فقط** على مرشّحات beam. ### B.1 توسعة `lm_decoder.py` — top-K أعد هيكلة بناء المصفوفة في دالة مشتركة، ثم أضِف: ``` def _forward_matrix(self, model, pil_line_image) -> (mat: np.ndarray[C,W], idx2char: dict) # نفس منطق decode_line الحالي حتى الحصول على mat + idx2char (أعد الاستخدام، لا تكرّر) def decode_matrix_topk(self, mat, idx2char, k=5) -> list[(str_visual, logprob)] # نفس حلقة beam في decode_matrix، لكن في النهاية: # - ادمج الأشعة حسب السلسلة النهائية (قد تُنتج بادئات مختلفة نفس النص بعد الطيّ) # - score = logaddexp(pb, pnb) لكل سلسلة فريدة # - أعد أعلى k سلاسل **متمايزة** تنازلياً def decode_line_topk(self, model, pil_line_image, k=5) -> list[{"text": str_logical, "score": float}] # mat, idx2char = self._forward_matrix(...) # cands = self.decode_matrix_topk(mat, idx2char, k) # حوّل كل نص للمنطقي عبر _to_logical # softmax على logprobs → احتمال [0..1] # ادمج المكرّرات بعد التحويل المنطقي (اجمع احتمالاتها) ``` - يجب أن يطابق **أول عنصر** من `decode_line_topk` ناتجَ `decode_line` الحالي (اختبار اتساق). ### B.2 تعديل `kraken_service.py` — إتاحة صور الأسطر بدقّة + ميزة المرشّحات - **التحسين #1 (الأهم — الجودة):** المرشّحات يجب أن تُحسب من **نفس صورة السطر التي رآها المعرّف** (مقصوصة من `bw_img` بقناع المضلّع via `extract_polygons`)، **لا** من قصّ خام لـ bbox من الصورة الأصلية (مجال مختلف → جودة أسوأ). - أثناء `extract_text_with_coordinates`: خزّن صور الأسطر المستخرجة في كاش LRU على المحرّك: `self._line_img_cache[img_hash] = [PIL_line_0, PIL_line_1, ...]` (سعة محدودة مثل 8 صور، احذف الأقدم). `img_hash` متاح عبر `database.get_image_hash`. - افصل بناء صور الأسطر عن `USE_LM` (احسبها إذا كانت ميزة المرشّحات مفعّلة `settings.CANDIDATES_ENABLED` أو دائماً مع كاش خفيف). - أضِف decoder للمرشّحات حتى لو `USE_LM=false`: أنشئ `LMDecoder` كسولاً عند أول طلب مرشّحات (`self._cand_decoder`)، يحمّل `settings.LM_PATH` إن وُجد؛ وإلا يعمل acoustic فقط (alpha=0). - أضِف دالة: ``` def line_candidates(self, img_hash, line_index, k=5) -> list[{"text","score"}] # احضر PIL من self._line_img_cache[img_hash][line_index] # decoder = self._cand_decoder # return decoder.decode_line_topk(self.rec_model, pil, k) ``` - **التحسين #3 (تحكّم بالكلفة):** اختياري `settings.AUTO_CANDIDATES` (افتراضي OFF): عند التشغيل، للأسطر `conf < τ` فقط (τ=70) احسب top-K مسبقاً وألصقها في السطر المُرجَع كـ `record["candidates"]` (حدّ أقصى لعدد الأسطر التلقائية/صفحة مثل 15). هذا يربط ميزة (4) التمييز بميزة (3). ### B.3 endpoint جديد في `analysis_api.py` ``` POST /api/analyze/line-candidates Request (وضعان): أ) المُخزَّن: {"img_hash": "...", "line_index": 3, "k": 5, "context": "<اختياري: نص الصفحة>"} ب) المباشر: {"line_image_b64": "", "k": 5, "context": "..."} Response 200: { "line_index": 3, "candidates": [{"text":"...","score":0.61,"source":"beam"}, ...], // مرتّبة بدرجة الصورة "best": "<الأرجح بعد الترجيح>", "reranked_by": "llm" | "char_lm" | "none", "reason": "<سبب موجز إن llm>" } ``` المنطق: 1. احصل على صورة السطر: من كاش المحرّك (وضع أ) أو فك base64 (وضع ب). 2. `cands = ocr_engine.line_candidates(...)` أو `_cand_decoder.decode_line_topk` للصورة المباشرة. 3. **التحسين #2 (شفافية):** أبقِ درجات الصورة كما هي. إن توفّر `context` و`llm_service`: أعد ترتيب المرشّحات بالسياق (وسّع برومبت `word-candidates` ليقبل قائمة قراءات سطر كامل ويُرجع `ranked/best/reason`). `reranked_by="llm"`. 4. **التحسين #7 (احتياط بلا إنترنت):** إن غاب LLM لكن وُجد `char8` LM: رجّح المرشّحات بدرجة char-LM داخل السياق. `reranked_by="char_lm"`. 5. **التحسين #10 (كاش):** خزّن النتيجة بمفتاح `(img_hash, line_index, k)`. 6. **التحسين #9 (تدرّج آمن):** beam(صورة) → إن غاب decoder: مرشّحات القاموس+التردد (الخطة A) على كلمات السطر → إن فشل: أعد النص الأصلي. لا ترمِ 500. ### B.4 الواجهة - `ocr.py`: ضمّن `img_hash` في حمولة حدث `ocr_data` (SSE) ونتائج المهام، ليحفظه العميل في الحالة. - `api.js`: أضِف `getLineCandidates({imgHash, lineIndex, context, k})` بنمط `_postJSON`. - `app.js`: خزّن `currentImgHash`. في `#line-popup` (يُفتح بنقر سطر) أضِف قسم «بدائل القراءة» + زر «❓ بدائل» يظهر **فقط** للأسطر المشكوكة (conf منخفض أو مميّزة من ميزة 4) أو عند اختيار المستخدم يدوياً. عند النقر: استدعِ `getLineCandidates`، اعرض المرشّحات مرتّبة بنسبها المئوية؛ نقر مرشّح → استبدل `currentData[i].text` + أعد العرض + حدّث `#full-preview`. - **التحسين #8:** أبرِز فرق الأحرف بين المرشّح والنص الأصلي (تمييز بصري للحروف المختلفة). - CSS بنمط `.analysis-*`/`.cand-list` الموجود. ### B.5 تحسينات B (ملخّص لتطبّقها) 1. صورة السطر الدقيقة (مقنّعة بالمضلّع من bw) عبر كاش، لا قصّ bbox خام. **[جودة]** 2. فصل درجات الصورة عن ترجيح السياق وإظهارهما (شفافية: «الصورة ترجّح س، السياق يرجّح ص»). 3. الحساب للأسطر المشكوكة فقط + on-demand؛ K≤5؛ حدّ أقصى أسطر تلقائية/صفحة. **[كلفة]** 4. دمج المرشّحات المتطابقة بعد التحويل المنطقي/التطبيع (لا تكرار). 5. softmax على درجات beam → نسبة مئوية لكل مرشّح. 6. ضمان تمايز سلاسل beam (ادمج بادئات تُعطي نفس النص). 7. احتياط char-LM للترجيح حين لا LLM. 8. إبراز فرق الأحرف بين المرشّحات. 9. سلسلة تدرّج آمنة beam → قاموس(A) → الأصل. 10. كاش المرشّحات بمفتاح (img_hash, line_index). ### B.6 معايير القبول + خطة الاختبار (تُشغَّل في Docker) - **اتساق:** أول عنصر من `decode_line_topk` == ناتج `decode_line` لنفس السطر. - **تمايز:** `decode_matrix_topk` يُرجع ≤k سلاسل **فريدة** مرتّبة تنازلياً؛ مجموع احتمالات `decode_line_topk` ≈ 1. - **عدم انحدار:** التعرّف الافتراضي بلا تغيير حين الميزة مطفأة؛ `py_compile` لكل الملفات يمرّ. - **الـ endpoint:** يُرجع 200 ومرشّحات لسطر مشكوك معروف؛ ويتدرّج بأمان (200 + احتياط) عند غياب decoder؛ بلا 500. - **pytest:** (1) `decode_matrix_topk` على مصفوفة اصطناعية صغيرة، (2) تكامل الـ endpoint على صورة سطر عيّنة، (3) اختبار الاتساق أعلاه. - **اختبار يدوي UI:** رفع مخطوطة → فتح سطر مشكوك → «❓ بدائل» → ظهور قائمة مرتّبة → اختيار يستبدل النص. ### B.7 خارج النطاق (لا تفعله الآن) - مرشّحات على مستوى **الكلمة** (يحتاج قصّ كلمات لا يوفّره kraken) — لاحقاً. - البحث الدلالي بالـ embeddings — مؤجّل. --- ## ترتيب التسليم المطلوب من Codex 1. الخطة A كاملة (سكربت + تعديل القاموس + اختبارها). 2. B.1 + B.6 اختبارات decoder (وحدات). 3. B.2 + B.3 (خادم) + اختبار تكامل. 4. B.4 (واجهة) + اختبار يدوي. بعد كل مرحلة: `py_compile`/`node --check` + تقرير موجز بما تغيّر والملفات المعدّلة. **النتائج تُسلَّم للمراجعة قبل اعتماد المرحلة التالية.**