Zero-Shot Classification
Transformers
Safetensors
Arabic
bert
feature-extraction
arabic
prompt-routing
router
encoder
tiny-model
Instructions to use oddadmix/Nawah-Router-BERT-6M-v2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oddadmix/Nawah-Router-BERT-6M-v2 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("zero-shot-classification", model="oddadmix/Nawah-Router-BERT-6M-v2")# Load model directly from transformers import AutoTokenizer, AutoModel tokenizer = AutoTokenizer.from_pretrained("oddadmix/Nawah-Router-BERT-6M-v2") model = AutoModel.from_pretrained("oddadmix/Nawah-Router-BERT-6M-v2", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """ | |
| Arabic prompt-routing corpus v2 — many routing axes, and lane sets that mix them. | |
| v1 (router_common.py) generated one axis only: topical intent inside a business vertical, 18 | |
| domains. It reached 0.9199 on unseen lane sets but stalled at 0.6665 on unseen domains, and the | |
| reference demo shows why that is the wrong shape rather than merely too small. LiquidAI's shipped | |
| presets mix axes inside a single lane set: | |
| Simple function call · Simple tool use · Complex multi-step agentic task · | |
| Quick factual question · Casual conversation · Creative writing · Translation · | |
| Needs a bigger model | |
| That is complexity, task type, tool use and an escalation lane together. Every v1 lane set was | |
| single-axis, so the model never saw categories that differ along different dimensions at once. | |
| Three generation modes here: | |
| topical - v1's mode, widened from 18 to 45 verticals. The model invents the lanes. | |
| axis - lanes drawn from one of ten taxonomies (task type, complexity, safety, retrieval, | |
| tools, subject, subdomain, language, urgency, tone), each with several paraphrased | |
| wordings so the model learns the concept and not the string. | |
| mixed - lanes sampled across two or three axes, as the reference does. The largest share. | |
| `router_common.py` is deliberately untouched: it defines build_task for task ids 100000-116063 and | |
| editing it would silently redraw prompts for the 51,392 rows already generated. This module owns a | |
| disjoint range (START_TASK=1000000) and its output is merged with v1's at build time. | |
| Labels are still established by construction and confirmed by the independent back-classification | |
| in verify_router.py, which is axis-agnostic and needs no change. | |
| """ | |
| import random | |
| import re | |
| import unicodedata | |
| ITEMS_PER_TASK = 4 | |
| C, Q, A, E = "### فئات", "### نص", "### الفئة", "### نهاية" | |
| # Varied on purpose. v1 used one fixed string, which teaches the token rather than the concept; | |
| # the reference ships a catch-all in most presets under several different names. | |
| OTHER_LANES = ["أخرى / لا ينطبق", "غير ذلك", "لا ينطبق أي مما سبق", "أخرى", | |
| "خارج التصنيفات المذكورة"] | |
| # ---------------------------------------------------------------- axis taxonomies | |
| # Each lane carries several wordings; build_task picks one per task so the corpus never ties a | |
| # concept to a single surface form. Wordings deliberately avoid a shared give-away token | |
| # (e.g. not every complexity lane contains بسيط/معقد). | |
| AXES = { | |
| "task_type": { | |
| "hint": "مستخدم يطلب من مساعد ذكي مهمة", | |
| "lanes": [ | |
| ["ترجمة", "نقل نص من لغة إلى أخرى", "طلب ترجمة"], | |
| ["برمجة", "كتابة أو إصلاح كود", "مهمة برمجية"], | |
| ["تلخيص", "اختصار نص طويل", "طلب ملخّص"], | |
| ["كتابة إبداعية", "تأليف قصة أو نص أدبي", "صياغة محتوى إبداعي"], | |
| ["رياضيات", "حساب أو مسألة رياضية", "عملية حسابية"], | |
| ["استخراج بيانات", "انتزاع حقول من نص", "تحويل نص إلى بيانات منظمة"], | |
| ["سؤال معلومات", "طلب معلومة مباشرة", "سؤال معرفي عام"], | |
| ["محادثة", "دردشة غير مهمّية", "كلام عابر"], | |
| ["إعادة صياغة", "تحسين صياغة نص قائم", "تحرير وتنقيح"], | |
| ["تصنيف", "تحديد فئة نص", "فرز محتوى"], | |
| ], | |
| }, | |
| "complexity": { | |
| "hint": "مستخدم يكتب طلبًا يتفاوت في صعوبته", | |
| "lanes": [ | |
| ["طلب بسيط ومباشر", "يُنجز بخطوة واحدة", "مهمة سهلة لا تحتاج تفكيرًا"], | |
| ["طلب متوسط", "يحتاج خطوتين أو ثلاثًا", "مهمة معتدلة"], | |
| ["طلب معقّد متعدد الخطوات", "يحتاج تخطيطًا وتسلسلًا طويلًا", "مهمة مركّبة"], | |
| ["يحتاج نموذجًا أكبر", "يفوق قدرة نموذج صغير", "يُحال إلى نموذج أقوى"], | |
| ["يحتاج استدلالًا خطوة بخطوة", "يتطلب سلسلة تفكير", "حل يحتاج برهنة"], | |
| ], | |
| }, | |
| "safety": { | |
| "hint": "رسالة واردة تحتاج فرزًا من ناحية السلامة", | |
| "lanes": [ | |
| ["محتوى آمن", "لا مشكلة فيه", "مقبول للنشر"], | |
| ["رسائل مزعجة", "إعلانات متكررة غير مرغوبة", "سبام تجاري"], | |
| ["يحتوي بيانات شخصية", "فيه معلومات تعريف حساسة", "يكشف خصوصية أحدهم"], | |
| ["لغة مسيئة", "ألفاظ جارحة", "إهانة أو تنمّر"], | |
| ], | |
| }, | |
| "retrieval": { | |
| "hint": "سؤال قد يحتاج مصدرًا خارجيًا أو لا", | |
| "lanes": [ | |
| ["يُجاب من المعرفة العامة", "لا يحتاج بحثًا", "إجابة مباشرة بلا مصادر"], | |
| ["يحتاج بحثًا واحدًا", "يكفيه مصدر واحد", "استعلام واحد يكفي"], | |
| ["يحتاج بحثًا متعدد الخطوات", "يجمع بين أكثر من مصدر", "يتطلب تتبع عدة معلومات"], | |
| ["يحتاج معلومة لحظية", "يعتمد على بيانات متغيرة الآن", "يحتاج تحديثًا آنيًا"], | |
| ], | |
| }, | |
| "tools": { | |
| "hint": "طلب قد يستدعي أداة معينة", | |
| "lanes": [ | |
| ["بحث في الويب", "استعلام محرك بحث", "تصفح الإنترنت"], | |
| ["حاسبة", "إجراء عملية حسابية", "أداة حساب"], | |
| ["تقويم ومواعيد", "جدولة موعد", "إدارة الأجندة"], | |
| ["بريد ورسائل", "إرسال رسالة", "التعامل مع البريد"], | |
| ["قاعدة بيانات", "استعلام من سجلات", "جلب صف من جدول"], | |
| ["لا يحتاج أداة", "يُجاب بلا استدعاء خارجي", "بدون أدوات"], | |
| ["تشغيل كود", "تنفيذ سكربت", "مفسّر برمجي"], | |
| ], | |
| }, | |
| "subject": { | |
| "hint": "سؤال أكاديمي في تخصص ما", | |
| "lanes": [ | |
| ["أحياء", "علوم الحياة", "بيولوجيا"], | |
| ["قانون", "مسائل قانونية", "تشريع وأنظمة"], | |
| ["اقتصاد", "مسائل اقتصادية", "اقتصاد وأسواق"], | |
| ["هندسة", "مسائل هندسية", "تصميم وتنفيذ هندسي"], | |
| ["طب وصحة", "مسائل طبية", "صحة وعلاج"], | |
| ["تاريخ", "أحداث ووقائع تاريخية", "ماضٍ وسير"], | |
| ["فلسفة", "مسائل فلسفية", "فكر ومنطق"], | |
| ["فيزياء", "مسائل فيزيائية", "قوانين الطبيعة"], | |
| ["حاسوب", "علوم الحاسب", "تقنية معلومات"], | |
| ["علم نفس", "مسائل نفسية", "سلوك وإدراك"], | |
| ], | |
| }, | |
| "language": { | |
| "hint": "رسالة مكتوبة بلهجة أو لغة ما", | |
| "lanes": [ | |
| ["عربية فصحى", "لغة عربية رسمية", "فصحى معاصرة"], | |
| ["لهجة مصرية", "عامية مصر", "مصري"], | |
| ["لهجة خليجية", "عامية الخليج", "خليجي"], | |
| ["لهجة شامية", "عامية بلاد الشام", "شامي"], | |
| ["لهجة مغاربية", "عامية المغرب العربي", "مغاربي"], | |
| ["إنجليزية", "نص بالإنجليزية", "لغة إنجليزية"], | |
| ["عربي مختلط بالإنجليزية", "كتابة مزدوجة اللغة", "عربيزي أو تبديل لغوي"], | |
| ], | |
| }, | |
| "urgency": { | |
| "hint": "رسالة تتفاوت في إلحاحها", | |
| "lanes": [ | |
| ["عاجل", "يحتاج تدخلًا فوريًا", "أولوية قصوى"], | |
| ["أولوية عادية", "يمكن معالجته ضمن الدور", "غير مستعجل"], | |
| ["أولوية منخفضة", "يمكن تأجيله", "لا يستدعي استعجالًا"], | |
| ], | |
| }, | |
| "tone": { | |
| "hint": "رسالة من عميل بنبرة معينة", | |
| "lanes": [ | |
| ["شكوى غاضبة", "امتعاض شديد", "نبرة تصعيد"], | |
| ["استفسار محايد", "سؤال هادئ", "نبرة عادية"], | |
| ["شكر وثناء", "رسالة امتنان", "إطراء على الخدمة"], | |
| ["طلب اعتذار أو تعويض", "مطالبة بحق", "طلب جبر ضرر"], | |
| ], | |
| }, | |
| } | |
| # Mixed lane sets use COMPOUND lanes, not a union of two axes' lanes. | |
| # | |
| # The first attempt sampled lanes from two axes at once and produced ill-posed rows: an insulting | |
| # message written in Gulf dialect belongs to both 'لهجة خليجية' and 'إهانة أو تنمر', and the task | |
| # is single-label. Constraining to "compatible" axes did not fix it either - an angry complaint is | |
| # also urgent. Any two independent axes overlap by construction. | |
| # | |
| # The reference avoids this because its lanes are already compound: "Simple function call" fuses | |
| # complexity with tool use, "Complex multi-step agentic task" fuses complexity with task type. | |
| # Each lane names a point in the joint space, so the set partitions rather than overlaps. These | |
| # pools mirror that, one per facet-family; a lane set is drawn from a single pool. | |
| COMPOUND_POOLS = { | |
| "assistant": ( | |
| "مستخدم يطلب من مساعد ذكي مهمة", | |
| ["استدعاء دالة بسيط", "استخدام أداة بسيط", "مهمة وكيلة معقدة متعددة الخطوات", | |
| "استدلال بسيط", "استدلال معقد يحتاج خطوات", "مهمة برمجية بسيطة", | |
| "مهمة برمجية معقدة", "إصلاح خطأ برمجي صغير", "إعادة هيكلة كود واسعة", | |
| "مراجعة كود", "كتابة اختبارات", "شرح كود قائم", | |
| "مسألة حسابية بسيطة", "مسألة رياضية معقدة", "ترجمة", "تلخيص", | |
| "كتابة إبداعية", "إعادة صياغة نص", "بحث عن معلومة مباشرة", | |
| "سؤال سريع تُعرف إجابته", "محادثة عابرة", "يحتاج نموذجًا أكبر", | |
| "يحتاج بحثًا في الويب", "يحتاج تنفيذ كود", "استعلام قاعدة بيانات", | |
| "تحليل بيانات", "إنشاء رسم بياني", "استخراج حقول من نص", | |
| "جدولة موعد في التقويم", "صياغة رسالة بريد"], | |
| ), | |
| "triage": ( | |
| "رسالة واردة إلى فريق خدمة العملاء", | |
| ["شكوى غاضبة تحتاج تصعيدًا فوريًا", "استفسار عادي غير مستعجل", | |
| "بلاغ عطل حرج يوقف الخدمة", "بلاغ عطل بسيط يمكن تأجيله", | |
| "طلب استرجاع مبلغ", "طلب إلغاء اشتراك", "رسالة شكر وثناء", | |
| "رسالة مزعجة إعلانية", "إساءة لفظية تستوجب حظرًا", | |
| "طلب حذف بيانات شخصية", "استفسار عن الفاتورة", "طلب ميزة جديدة", | |
| "مشكلة في الدخول إلى الحساب", "سؤال عام عن الخدمة"], | |
| ), | |
| "content": ( | |
| "نص يُراد تصنيف موضوعه ومستواه", | |
| ["خبر سياسي", "خبر رياضي", "خبر اقتصادي", "تقرير علمي مبسّط", | |
| "مقال رأي", "محتوى تعليمي للمبتدئين", "محتوى تقني متقدم", | |
| "وصفة أو إرشادات عملية", "مراجعة منتج", "إعلان تجاري", | |
| "نص قانوني رسمي", "نص طبي متخصص", "محتوى ترفيهي خفيف"], | |
| ), | |
| } | |
| # "Which flavour of X" — the reference's `lang` preset generalised. The model invents the members. | |
| SUBDOMAIN_FAMILIES = [ | |
| ("لغة البرمجة المستخدمة في السؤال", "مبرمج يسأل عن لغة معينة"), | |
| ("نوع الوثيقة الرسمية المطلوبة", "مواطن يطلب مستندًا"), | |
| ("فرع التأمين المعني", "عميل يسأل عن وثيقته"), | |
| ("نوع الحساب البنكي المقصود", "عميل بنك"), | |
| ("تخصص الطبيب المطلوب", "مريض يبحث عن عيادة"), | |
| ("نوع العقار المطلوب", "باحث عن سكن"), | |
| ("نوع الشحنة المرسلة", "عميل شركة شحن"), | |
| ("مرحلة دراسية", "ولي أمر يسأل عن التسجيل"), | |
| ] | |
| # v1's 18, plus 27 more. | |
| DOMAINS = [ | |
| ("دعم فني لشركة برمجيات", "مستخدم يراسل الدعم الفني"), | |
| ("خدمة عملاء بنك", "عميل يتواصل مع بنكه"), | |
| ("متجر إلكتروني", "مشترٍ يسأل عن طلب أو منتج"), | |
| ("حجز سفر وطيران", "مسافر يرتب رحلة"), | |
| ("خدمات حكومية إلكترونية", "مواطن يطلب معاملة"), | |
| ("فرز طبي أولي", "مريض يصف حالته"), | |
| ("شركة اتصالات", "مشترك يسأل عن باقته"), | |
| ("منصة تعليمية", "طالب أو ولي أمر يستفسر"), | |
| ("موارد بشرية داخل شركة", "موظف يراسل الموارد البشرية"), | |
| ("وساطة عقارية", "باحث عن سكن أو مالك"), | |
| ("تطبيق توصيل طعام", "زبون يتابع طلبه"), | |
| ("استشارات قانونية", "شخص يسأل عن حقوقه"), | |
| ("تصنيف مواضيع الأخبار", "قارئ يبحث عن خبر"), | |
| ("مراجعة المحتوى والإبلاغ", "مستخدم يبلّغ عن محتوى"), | |
| ("توجيه مساعد ذكي", "مستخدم يطلب من مساعد ذكي مهمة"), | |
| ("إدارة مالية شخصية", "شخص يدير مصروفاته"), | |
| ("شركة تأمين", "عميل يسأل عن وثيقته"), | |
| ("صيانة منزلية", "ساكن يطلب صيانة"), | |
| ("مطعم وحجز طاولات", "زبون يحجز أو يشتكي"), | |
| ("صيدلية وطلب دواء", "مريض يسأل عن دواء"), | |
| ("نادٍ رياضي واشتراكات", "مشترك في صالة رياضية"), | |
| ("شحن ولوجستيات", "عميل يتابع شحنة"), | |
| ("معرض وصيانة سيارات", "مالك سيارة"), | |
| ("بنك استثماري ومحافظ", "مستثمر يسأل عن محفظته"), | |
| ("سياحة دينية وحج وعمرة", "معتمر ينظّم رحلته"), | |
| ("جامعة وقبول وتسجيل", "طالب جامعي"), | |
| ("مكتب محاماة", "موكّل يتابع قضيته"), | |
| ("زراعة وإرشاد زراعي", "مزارع يسأل عن محصوله"), | |
| ("خدمة كهرباء ومياه", "مشترك يبلّغ عن انقطاع"), | |
| ("تأجير سيارات", "مستأجر يحجز مركبة"), | |
| ("فندق وضيافة", "نزيل يحجز أو يشتكي"), | |
| ("منصة توظيف", "باحث عن عمل"), | |
| ("متجر إلكترونيات وضمان", "مشترٍ يطالب بالضمان"), | |
| ("خدمات بريدية", "مرسِل يتابع طردًا"), | |
| ("مركز تدريب ودورات", "متدرب يسجّل في دورة"), | |
| ("عيادة أسنان ومواعيد", "مريض يحجز موعدًا"), | |
| ("شركة تنظيف منازل", "زبون يطلب خدمة"), | |
| ("منصة بث ومحتوى", "مشترك يسأل عن اشتراكه"), | |
| ("متجر ملابس ومقاسات", "زبون يستبدل قطعة"), | |
| ("خدمة استضافة ونطاقات", "صاحب موقع"), | |
| ("جمعية خيرية وتبرعات", "متبرع يسأل"), | |
| ("نقل عام وتذاكر", "راكب يستفسر"), | |
| ("مكتبة وبيع كتب", "قارئ يطلب كتابًا"), | |
| ("خدمة محاسبة وضرائب", "صاحب منشأة"), | |
| ("متجر حيوانات أليفة", "مربّي حيوان"), | |
| ] | |
| STYLES = { | |
| "noun": ("كلمة واحدة أو كلمتين", "مثل: برمجة، فواتير، شكاوى"), | |
| "phrase": ("عبارة قصيرة من ٢ إلى ٤ كلمات", "مثل: كتابة إبداعية، إلغاء الاشتراك"), | |
| "desc": ("جملة وصفية كاملة", "مثل: أسئلة حول الفواتير والمدفوعات وطلبات الاسترجاع"), | |
| } | |
| DIFFICULTY = { | |
| "easy": "اجعل الفئات متباعدة وواضحة الفروق بينها.", | |
| "hard": ("اجعل بعض الفئات متقاربة جدًا في المعنى بحيث يصعب التمييز بينها دون قراءة النص " | |
| "بعناية."), | |
| } | |
| # Mode 1: the model invents the lanes (topical / subdomain). | |
| PROMPT_INVENT = """أنت تكتب بيانات تدريبية لنموذج يوجّه النصوص إلى فئات يحددها المستخدم. | |
| المجال: {domain} ({hint}) | |
| الخطوة الأولى — اقترح {n} فئة للتوجيه في هذا المجال: | |
| - اكتب كل فئة بأسلوب: {style_desc} ({style_ex}). | |
| - {difficulty} | |
| - الفئات يجب أن تغطي المجال دون تداخل كامل بينها.{other_note} | |
| الخطوة الثانية — اكتب {k} نصوص مستقلة، كل نص من شخص حقيقي ({hint}). | |
| النص رقم i يجب أن ينتمي إلى الفئة المطلوبة له في القائمة أدناه: | |
| {targets} | |
| {fmt}""" | |
| # Mode 2/3: the lanes are given (single axis or mixed axes); the model only writes the texts. | |
| PROMPT_GIVEN = """أنت تكتب بيانات تدريبية لنموذج يوجّه النصوص إلى فئات يحددها المستخدم. | |
| الفئات المعطاة (استخدمها كما هي حرفيًا وبنفس الترتيب): | |
| {lanes_block} | |
| السياق: {hint} | |
| اكتب {k} نصوص عربية مستقلة وواقعية. النص رقم i يجب أن ينتمي إلى الفئة المطلوبة له: | |
| {targets} | |
| {fmt}""" | |
| FMT = """اكتب بهذا الشكل بالضبط: | |
| {C} | |
| (فئة في كل سطر، مسبوقة بـ "- "، بالترتيب وبنفس الصياغة في كل مرة) | |
| {Q} | |
| (النص العربي الأول، من سطر إلى ثلاثة أسطر) | |
| {A} | |
| (اسم الفئة التي ينتمي إليها هذا النص، منسوخًا حرفيًا من قائمة الفئات) | |
| {Q} | |
| (النص الثاني) | |
| {A} | |
| (فئته) | |
| وهكذا حتى {k} نصوص | |
| {E} | |
| قواعد مهمة: | |
| - لا تذكر اسم الفئة داخل النص نفسه. النص يجب أن يدل على فئته بالمعنى لا بالكلمة. | |
| - اكتب نصوصًا طبيعية كما يكتبها الناس فعلًا، بأطوال مختلفة. | |
| - نوّع الأسماء والتفاصيل والأسلوب بين النصوص. | |
| - لا تكتب أي شيء خارج الوسوم.""" | |
| def _targets_block(rng, n_lanes, has_other): | |
| real = list(range(n_lanes)) | |
| targets = [rng.choice(real) for _ in range(ITEMS_PER_TASK)] | |
| if has_other: | |
| targets[rng.randrange(ITEMS_PER_TASK)] = n_lanes | |
| return targets, "\n".join(f"- النص {i+1}: الفئة رقم {t+1}" | |
| for i, t in enumerate(targets)) | |
| def build_task(task_id: int, seed: int = 1234): | |
| """task_id -> (axes, prompt). Deterministic, so a resumed run redraws identical prompts.""" | |
| rng = random.Random(seed * 7_000_003 + task_id) | |
| mode = rng.choices(["mixed", "axis", "topical"], weights=[45, 35, 20])[0] | |
| style = rng.choice(["noun", "phrase", "phrase", "desc"]) | |
| difficulty = "hard" if rng.random() < 0.4 else "easy" | |
| has_other = rng.random() < 0.30 # v1 used 0.15; the reference uses a catch-all often | |
| other_lane = rng.choice(OTHER_LANES) | |
| fmt = FMT.format(C=C, Q=Q, A=A, E=E, k=ITEMS_PER_TASK) | |
| if mode == "topical": | |
| use_sub = rng.random() < 0.2 | |
| domain, hint = rng.choice(SUBDOMAIN_FAMILIES if use_sub else DOMAINS) | |
| n = rng.choice([2, 3, 3, 4, 4, 4, 5, 5, 6, 7, 8]) | |
| sd, sx = STYLES[style] | |
| other_note = (f"\n- أضف في نهاية القائمة فئة أخيرة اسمها بالضبط: {other_lane}" | |
| "\n- واجعل أحد النصوص لا ينتمي لأي فئة إلا هذه الفئة الأخيرة.") \ | |
| if has_other else "" | |
| targets, tgt = _targets_block(rng, n, has_other) | |
| axes = {"mode": mode, "axis": "subdomain" if use_sub else "topical", "domain": domain, | |
| "style": style, "difficulty": difficulty, "has_other": has_other, | |
| # total lane count, Other included - the axis/mixed branch counts the same way | |
| "n_routes": n + (1 if has_other else 0), "targets": targets} | |
| return axes, PROMPT_INVENT.format(domain=domain, hint=hint, n=n, k=ITEMS_PER_TASK, | |
| style_desc=sd, style_ex=sx, | |
| difficulty=DIFFICULTY[difficulty], | |
| other_note=other_note, targets=tgt, fmt=fmt) | |
| if mode == "axis": | |
| name = rng.choice(list(AXES)) | |
| spec = AXES[name] | |
| k = min(len(spec["lanes"]), rng.choice([3, 3, 4, 4, 5, 5, 6, 7])) | |
| picked = rng.sample(spec["lanes"], k) | |
| lanes = [rng.choice(w) for w in picked] # one wording per lane, per task | |
| hint = spec["hint"] | |
| axis_label, domain = name, name | |
| else: # mixed - compound lanes from one pool | |
| pool_name = rng.choice(list(COMPOUND_POOLS)) | |
| hint, pool = COMPOUND_POOLS[pool_name] | |
| k = min(len(pool), rng.choice([3, 4, 4, 5, 5, 6, 6, 7, 8])) | |
| lanes = rng.sample(pool, k) | |
| axis_label, domain = "compound:" + pool_name, pool_name | |
| if has_other: | |
| lanes = lanes + [other_lane] | |
| rng.shuffle(lanes) | |
| n = len(lanes) - (1 if has_other else 0) | |
| other_idx = lanes.index(other_lane) if has_other else None | |
| real_idx = [i for i in range(len(lanes)) if i != other_idx] | |
| targets = [rng.choice(real_idx) for _ in range(ITEMS_PER_TASK)] | |
| if has_other: | |
| targets[rng.randrange(ITEMS_PER_TASK)] = other_idx | |
| tgt = "\n".join(f"- النص {i+1}: الفئة رقم {t+1}" for i, t in enumerate(targets)) | |
| lanes_block = "\n".join(f"- {c}" for c in lanes) | |
| axes = {"mode": mode, "axis": axis_label, "domain": domain, "style": style, | |
| "difficulty": difficulty, "has_other": has_other, | |
| "n_routes": len(lanes), "targets": targets} | |
| return axes, PROMPT_GIVEN.format(lanes_block=lanes_block, hint=hint, k=ITEMS_PER_TASK, | |
| targets=tgt, fmt=fmt) | |
| # ---------------------------------------------------------------- parsing / validation | |
| # Identical contract to router_common; the tag format and structural checks carry over unchanged. | |
| BLOCK_RE = re.compile(re.escape(C) + r"(?P<cats>.*?)(?=" + re.escape(Q) + r")", re.S) | |
| PAIR_RE = re.compile(re.escape(Q) + r"(?P<text>.*?)" + re.escape(A) + r"(?P<label>.*?)" | |
| r"(?=" + re.escape(Q) + r"|" + re.escape(E) + r"|\Z)", re.S) | |
| def norm(s: str) -> str: | |
| s = unicodedata.normalize("NFKC", s or "") | |
| s = re.sub(r"[ً-ْٰـ]", "", s) | |
| return re.sub(r"\s+", " ", s).strip() | |
| def parse_items(raw: str): | |
| m = BLOCK_RE.search(raw or "") | |
| if not m: | |
| return [] | |
| routes = [] | |
| for line in m.group("cats").split("\n"): | |
| line = line.strip() | |
| c = re.match(r"^[-*•]\s*(.+)$", line) or re.match(r"^\d+[.)]\s*(.+)$", line) | |
| if c: | |
| r = norm(c.group(1)).strip(" .:،") | |
| if 2 <= len(r) <= 120: | |
| routes.append(r) | |
| if not routes: | |
| return [] | |
| out = [] | |
| for p in PAIR_RE.finditer(raw): | |
| out.append({"routes": routes, "text": norm(p.group("text")), | |
| "label_text": norm(p.group("label")).strip(" .:،").lstrip("- ")}) | |
| return out | |
| def _match_route(label, routes): | |
| if label in routes: | |
| return routes.index(label) | |
| hits = [i for i, r in enumerate(routes) if r and (r in label or label in r)] | |
| return hits[0] if len(hits) == 1 else None | |
| def validate(item, min_text=15, max_text=600): | |
| """Structural only. Semantic correctness is decided by verify_router.py, not here.""" | |
| routes, text, label = item["routes"], item["text"], item["label_text"] | |
| if not (2 <= len(routes) <= 9): | |
| return False, "route_count" | |
| if len(set(routes)) != len(routes): | |
| return False, "duplicate_routes" | |
| if not (min_text <= len(text) <= max_text): | |
| return False, "text_length" | |
| if any(t in text for t in (C, Q, A, E)): | |
| return False, "tag_leak" | |
| idx = _match_route(label, routes) | |
| if idx is None: | |
| return False, "label_not_in_routes" | |
| if routes[idx] and routes[idx] in text: | |
| return False, "lexical_giveaway" | |
| if len(text.split()) < 4: | |
| return False, "text_too_few_words" | |
| return True, "ok" | |
| def label_index(item): | |
| return _match_route(item["label_text"], item["routes"]) | |
| def dedup_key(text: str) -> str: | |
| return re.sub(r"\d+", "#", re.sub(r"\W+", "", norm(text))) | |
| def lane_set_key(routes) -> str: | |
| return "|".join(sorted(norm(r) for r in routes)) | |