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 <path...> --out app/services/data/lexicon.json --min-count 2 - المنطق:
- اقرأ كل نصوص GT (ابحث عن
.txt/المدوّنة المستخدمة فيlanguage_model/؛ إن لم تجد، اقبل مساراً يدوياً عبر--corpus). - قسّم إلى كلمات: احتفظ بالرموز العربية
[-ۿ]+، طول ≥2. - خزّن نسختين لكل كلمة: الأصلية + المطبّعة (إزالة تشكيل/توحيد همزات — أعد استخدام منطق
_normمنanalysis_api.py). - عُدّ الترددات؛ احذف ما تردده <
min_count. - اكتب
lexicon.json:{"words": {"<word>": freq, ...}, "total": N}مرتّبة تنازلياً. - اطبع تقريراً: حجم المفردات، أعلى 20 كلمة.
- اقرأ كل نصوص GT (ابحث عن
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بقناع المضلّع viaextract_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": "<png base64>", "k": 5, "context": "..."}
Response 200:
{
"line_index": 3,
"candidates": [{"text":"...","score":0.61,"source":"beam"}, ...], // مرتّبة بدرجة الصورة
"best": "<الأرجح بعد الترجيح>",
"reranked_by": "llm" | "char_lm" | "none",
"reason": "<سبب موجز إن llm>"
}
المنطق:
- احصل على صورة السطر: من كاش المحرّك (وضع أ) أو فك base64 (وضع ب).
cands = ocr_engine.line_candidates(...)أو_cand_decoder.decode_line_topkللصورة المباشرة.- التحسين #2 (شفافية): أبقِ درجات الصورة كما هي. إن توفّر
contextوllm_service: أعد ترتيب المرشّحات بالسياق (وسّع برومبتword-candidatesليقبل قائمة قراءات سطر كامل ويُرجعranked/best/reason).reranked_by="llm". - التحسين #7 (احتياط بلا إنترنت): إن غاب LLM لكن وُجد
char8LM: رجّح المرشّحات بدرجة char-LM داخل السياق.reranked_by="char_lm". - التحسين #10 (كاش): خزّن النتيجة بمفتاح
(img_hash, line_index, k). - التحسين #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 (ملخّص لتطبّقها)
- صورة السطر الدقيقة (مقنّعة بالمضلّع من bw) عبر كاش، لا قصّ bbox خام. [جودة]
- فصل درجات الصورة عن ترجيح السياق وإظهارهما (شفافية: «الصورة ترجّح س، السياق يرجّح ص»).
- الحساب للأسطر المشكوكة فقط + on-demand؛ K≤5؛ حدّ أقصى أسطر تلقائية/صفحة. [كلفة]
- دمج المرشّحات المتطابقة بعد التحويل المنطقي/التطبيع (لا تكرار).
- softmax على درجات beam → نسبة مئوية لكل مرشّح.
- ضمان تمايز سلاسل beam (ادمج بادئات تُعطي نفس النص).
- احتياط char-LM للترجيح حين لا LLM.
- إبراز فرق الأحرف بين المرشّحات.
- سلسلة تدرّج آمنة beam → قاموس(A) → الأصل.
- كاش المرشّحات بمفتاح (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
- الخطة A كاملة (سكربت + تعديل القاموس + اختبارها).
- B.1 + B.6 اختبارات decoder (وحدات).
- B.2 + B.3 (خادم) + اختبار تكامل.
- B.4 (واجهة) + اختبار يدوي.
بعد كل مرحلة:
py_compile/node --check+ تقرير موجز بما تغيّر والملفات المعدّلة. النتائج تُسلَّم للمراجعة قبل اعتماد المرحلة التالية.