oddadmix commited on
Commit
73d6651
·
verified ·
1 Parent(s): 7be08d2

add router_axes.py

Browse files
Files changed (1) hide show
  1. router_axes.py +460 -0
router_axes.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Arabic prompt-routing corpus v2 — many routing axes, and lane sets that mix them.
3
+
4
+ v1 (router_common.py) generated one axis only: topical intent inside a business vertical, 18
5
+ domains. It reached 0.9199 on unseen lane sets but stalled at 0.6665 on unseen domains, and the
6
+ reference demo shows why that is the wrong shape rather than merely too small. LiquidAI's shipped
7
+ presets mix axes inside a single lane set:
8
+
9
+ Simple function call · Simple tool use · Complex multi-step agentic task ·
10
+ Quick factual question · Casual conversation · Creative writing · Translation ·
11
+ Needs a bigger model
12
+
13
+ That is complexity, task type, tool use and an escalation lane together. Every v1 lane set was
14
+ single-axis, so the model never saw categories that differ along different dimensions at once.
15
+
16
+ Three generation modes here:
17
+
18
+ topical - v1's mode, widened from 18 to 45 verticals. The model invents the lanes.
19
+ axis - lanes drawn from one of ten taxonomies (task type, complexity, safety, retrieval,
20
+ tools, subject, subdomain, language, urgency, tone), each with several paraphrased
21
+ wordings so the model learns the concept and not the string.
22
+ mixed - lanes sampled across two or three axes, as the reference does. The largest share.
23
+
24
+ `router_common.py` is deliberately untouched: it defines build_task for task ids 100000-116063 and
25
+ editing it would silently redraw prompts for the 51,392 rows already generated. This module owns a
26
+ disjoint range (START_TASK=1000000) and its output is merged with v1's at build time.
27
+
28
+ Labels are still established by construction and confirmed by the independent back-classification
29
+ in verify_router.py, which is axis-agnostic and needs no change.
30
+ """
31
+ import random
32
+ import re
33
+ import unicodedata
34
+
35
+ ITEMS_PER_TASK = 4
36
+
37
+ C, Q, A, E = "### فئات", "### نص", "### الفئة", "### نهاية"
38
+
39
+ # Varied on purpose. v1 used one fixed string, which teaches the token rather than the concept;
40
+ # the reference ships a catch-all in most presets under several different names.
41
+ OTHER_LANES = ["أخرى / لا ينطبق", "غير ذلك", "لا ينطبق أي مما سبق", "أخرى",
42
+ "خارج التصنيفات المذكورة"]
43
+
44
+ # ---------------------------------------------------------------- axis taxonomies
45
+ # Each lane carries several wordings; build_task picks one per task so the corpus never ties a
46
+ # concept to a single surface form. Wordings deliberately avoid a shared give-away token
47
+ # (e.g. not every complexity lane contains بسيط/معقد).
48
+
49
+ AXES = {
50
+ "task_type": {
51
+ "hint": "مستخدم يطلب من مساعد ذكي مهمة",
52
+ "lanes": [
53
+ ["ترجمة", "نقل نص من لغة إلى أخرى", "طلب ترجمة"],
54
+ ["برمجة", "كتابة أو إصلاح كود", "مهمة برمجية"],
55
+ ["تلخيص", "اختصار نص طويل", "طلب ملخّص"],
56
+ ["كتابة إبداعية", "تأليف قصة أو نص أدبي", "صياغة محتوى إبداعي"],
57
+ ["رياضيات", "حساب أو مسألة رياضية", "عملية حسابية"],
58
+ ["استخراج بيانات", "انتزاع حقول من نص", "تحويل نص إلى بيانات منظمة"],
59
+ ["سؤال معلومات", "طلب معلومة مباشرة", "سؤال معرفي عام"],
60
+ ["محادثة", "دردشة غير مهمّية", "كلام عابر"],
61
+ ["إعادة صياغة", "تحسين صياغة نص قائم", "تحرير وتنقيح"],
62
+ ["تصنيف", "تحديد فئة نص", "فرز محتوى"],
63
+ ],
64
+ },
65
+ "complexity": {
66
+ "hint": "مستخدم يكتب طلبًا يتفاوت في صعوبته",
67
+ "lanes": [
68
+ ["طلب بسيط ومباشر", "يُنجز بخطوة واحدة", "مهمة سهلة لا تحتاج تفكيرًا"],
69
+ ["طلب متوسط", "يحتاج خطوتين أو ثلاثًا", "مهمة معتدلة"],
70
+ ["طلب معقّد متعدد الخطوات", "يحتاج تخطيطًا وتسلسلًا طويلًا", "مهمة مركّبة"],
71
+ ["يحتاج نموذجًا أكبر", "يفوق قدرة نموذج صغير", "يُحال إلى نموذج أقوى"],
72
+ ["يحتاج استدلالًا خطوة بخطوة", "يتطلب سلسلة تفكير", "حل يحتاج برهنة"],
73
+ ],
74
+ },
75
+ "safety": {
76
+ "hint": "رسالة واردة تحتاج فرزًا من ناحية السلامة",
77
+ "lanes": [
78
+ ["محتوى آمن", "لا مشكلة فيه", "مقبول للنشر"],
79
+ ["رسائل مزعجة", "إعلانات متكررة غير مرغوبة", "سبام تجاري"],
80
+ ["يحتوي بيانات شخصية", "فيه معلومات تعريف حساسة", "يكشف خصوصية أحدهم"],
81
+ ["لغة مسيئة", "ألفاظ جارحة", "إهانة أو تنمّر"],
82
+ ],
83
+ },
84
+ "retrieval": {
85
+ "hint": "سؤال قد يحتاج مصدرًا خارجيًا أو لا",
86
+ "lanes": [
87
+ ["يُجاب من المعرفة العامة", "لا يحتاج بحثًا", "إجابة مباشرة بلا مصادر"],
88
+ ["يحتاج بحثًا واحدًا", "يكفيه مصدر واحد", "استعلام واحد يكفي"],
89
+ ["يحتاج بحثًا متعدد الخطوات", "يجمع بين أكثر من مصدر", "يتطلب تتبع عدة معلومات"],
90
+ ["يحتاج معلومة لحظية", "يعتمد على بيانات متغيرة الآن", "يحتاج تحديثًا آنيًا"],
91
+ ],
92
+ },
93
+ "tools": {
94
+ "hint": "طلب قد يستدعي أداة معينة",
95
+ "lanes": [
96
+ ["بحث في الويب", "استعلام محرك بحث", "تصفح الإنترنت"],
97
+ ["حاسبة", "إجراء عملية حسابية", "أداة حساب"],
98
+ ["تقويم ومواعيد", "جدولة موعد", "إدارة الأجندة"],
99
+ ["بريد ورسائل", "إرسال رسالة", "التعامل مع البريد"],
100
+ ["قاعدة بيانات", "استعلام من سجلات", "جلب صف من جدول"],
101
+ ["لا يحتاج أداة", "يُجاب بلا استدعاء خارجي", "بدون أدوات"],
102
+ ["تشغيل كود", "تنفيذ سكربت", "مفسّر برمجي"],
103
+ ],
104
+ },
105
+ "subject": {
106
+ "hint": "سؤال أكاديمي في تخصص ما",
107
+ "lanes": [
108
+ ["أحياء", "علوم الحياة", "بيولوجيا"],
109
+ ["قانون", "مسائل قانونية", "تشريع وأنظمة"],
110
+ ["اقتصاد", "مسائل اقتصادية", "اقتصاد وأسواق"],
111
+ ["هندسة", "مسائل هندسية", "تصميم وتنفيذ هندسي"],
112
+ ["طب وصحة", "مسائل طبية", "صحة وعلاج"],
113
+ ["تاريخ", "أحداث ووقائع تاريخية", "ماضٍ وسير"],
114
+ ["فلسفة", "مسائل فلسفية", "فكر ومنطق"],
115
+ ["فيزياء", "مسائل فيزيائية", "قوانين الطبيعة"],
116
+ ["حاسوب", "علوم الحاسب", "تقنية معلومات"],
117
+ ["علم نفس", "مسائل نفسية", "سلوك وإدراك"],
118
+ ],
119
+ },
120
+ "language": {
121
+ "hint": "رسالة مكتوبة بلهجة أو لغة ما",
122
+ "lanes": [
123
+ ["عربية فصحى", "لغة عربية رسمية", "فصحى معاصرة"],
124
+ ["لهجة مصرية", "عامية مصر", "مصري"],
125
+ ["لهجة خليجية", "عامية الخليج", "خليجي"],
126
+ ["لهجة شامية", "عامية بلاد الشام", "شامي"],
127
+ ["لهجة مغاربية", "عامية المغرب العربي", "مغاربي"],
128
+ ["إنجليزية", "نص بالإنجليزية", "لغة إنجليزية"],
129
+ ["عربي مختلط بالإنجليزية", "كتابة مزدوجة اللغة", "عربيزي أو تبديل لغوي"],
130
+ ],
131
+ },
132
+ "urgency": {
133
+ "hint": "رسالة تتفاوت في إلحاحها",
134
+ "lanes": [
135
+ ["عاجل", "يحتاج تدخلًا فوريًا", "أولوية قصوى"],
136
+ ["أولوية عادية", "يمكن معالجته ضمن الدور", "غير مستعجل"],
137
+ ["أولوية منخفضة", "يمكن تأجيله", "لا يستدعي استعجالًا"],
138
+ ],
139
+ },
140
+ "tone": {
141
+ "hint": "رسالة من عميل بنبرة معينة",
142
+ "lanes": [
143
+ ["شكوى غاضبة", "امتعاض شديد", "نبرة تصعيد"],
144
+ ["استفسار محايد", "سؤال هادئ", "نبرة عادية"],
145
+ ["شكر وثناء", "رسالة امتنان", "إطراء على الخدمة"],
146
+ ["طلب اعتذار أو تعويض", "مطالبة بحق", "طلب جبر ضرر"],
147
+ ],
148
+ },
149
+ }
150
+
151
+ # Mixed lane sets use COMPOUND lanes, not a union of two axes' lanes.
152
+ #
153
+ # The first attempt sampled lanes from two axes at once and produced ill-posed rows: an insulting
154
+ # message written in Gulf dialect belongs to both 'لهجة خليجية' and 'إهانة أو تنمر', and the task
155
+ # is single-label. Constraining to "compatible" axes did not fix it either - an angry complaint is
156
+ # also urgent. Any two independent axes overlap by construction.
157
+ #
158
+ # The reference avoids this because its lanes are already compound: "Simple function call" fuses
159
+ # complexity with tool use, "Complex multi-step agentic task" fuses complexity with task type.
160
+ # Each lane names a point in the joint space, so the set partitions rather than overlaps. These
161
+ # pools mirror that, one per facet-family; a lane set is drawn from a single pool.
162
+ COMPOUND_POOLS = {
163
+ "assistant": (
164
+ "مستخدم يطلب من مساعد ذكي مهمة",
165
+ ["استدعاء دالة بسيط", "استخدام أداة بسيط", "مهمة وكيلة معقدة متعددة الخطوات",
166
+ "استدلال بسيط", "استدلال معقد يحتاج خطوات", "مهمة برمجية بسيطة",
167
+ "مهمة برمجية معقدة", "إصلاح خطأ برمجي صغير", "إعادة هيكلة كود واسعة",
168
+ "مراجعة كود", "كتابة اختبارات", "شرح كود قائم",
169
+ "مسألة حسابية بسيطة", "مسألة رياضية معقدة", "ترجمة", "تلخيص",
170
+ "كتابة إبداعية", "إعادة صياغة نص", "بحث عن معلومة مباشرة",
171
+ "سؤال سريع تُعرف إجابته", "محادثة عابرة", "يحتاج نموذجًا أكبر",
172
+ "يحتاج بحثًا في الويب", "يحتاج تنفيذ كود", "استعلام قاعدة بيانات",
173
+ "تحليل بيانات", "إنشاء رسم بياني", "استخراج حقول من نص",
174
+ "جدولة موعد في التقويم", "صياغة رسالة بريد"],
175
+ ),
176
+ "triage": (
177
+ "رسالة واردة إلى فريق خدمة العملاء",
178
+ ["شكوى غاضبة تحتاج تصعيدًا فوريًا", "استفسار عادي غير مستعجل",
179
+ "بلاغ عطل حرج يوقف الخدمة", "بلاغ عطل بسيط يمكن تأجيله",
180
+ "طلب استرجاع مبلغ", "طلب إلغاء اشتراك", "رسالة شكر وثناء",
181
+ "رسالة مزعجة إعلانية", "إساءة لفظية تستوجب حظرًا",
182
+ "طلب حذف بيانات شخصية", "استفسار عن الفاتورة", "طلب ميزة جديدة",
183
+ "مشكلة في الدخول إلى الحساب", "سؤال عام عن الخدمة"],
184
+ ),
185
+ "content": (
186
+ "نص يُراد تصنيف موضوعه ومستواه",
187
+ ["خبر سياسي", "خبر رياضي", "خبر اقتصادي", "تقرير علمي مبسّط",
188
+ "مقال رأي", "محتوى تعليمي للمبتدئين", "محتوى تقني متقدم",
189
+ "وصفة أو إرشادات عملية", "مراجعة منتج", "إعلان تجاري",
190
+ "نص قانوني رسمي", "نص طبي متخصص", "محتوى ترفيهي خفيف"],
191
+ ),
192
+ }
193
+
194
+ # "Which flavour of X" — the reference's `lang` preset generalised. The model invents the members.
195
+ SUBDOMAIN_FAMILIES = [
196
+ ("لغة البرمجة المستخدمة في السؤال", "مبرمج يسأل عن لغة معينة"),
197
+ ("نوع الوثيقة الرسمية المطلوبة", "مواطن يطلب مستندًا"),
198
+ ("فرع التأمين المعني", "عميل يسأل عن وثيقته"),
199
+ ("نوع الحساب البنكي المقصود", "عميل بنك"),
200
+ ("تخصص الطبيب المطلوب", "مريض يبحث عن عيادة"),
201
+ ("نوع العقار المطلوب", "باحث عن سكن"),
202
+ ("نوع الشحنة المرسلة", "عميل شركة شحن"),
203
+ ("مرحلة دراسية", "ولي أمر يسأل عن التسجيل"),
204
+ ]
205
+
206
+ # v1's 18, plus 27 more.
207
+ DOMAINS = [
208
+ ("دعم فني لشركة برمجيات", "مستخدم يراسل الدعم الفني"),
209
+ ("خدمة عملاء بنك", "عميل يتواصل مع بنكه"),
210
+ ("متجر إلكتروني", "مشترٍ يسأل عن طلب أو منتج"),
211
+ ("حجز سفر وطيران", "مسافر يرتب رحلة"),
212
+ ("خدمات حكومية إلكترونية", "مواطن يطلب معاملة"),
213
+ ("فرز طبي أولي", "مريض يصف حالته"),
214
+ ("شركة اتصالات", "مشترك يسأل عن باقته"),
215
+ ("منصة تعليمية", "طالب أو ولي أمر يستفسر"),
216
+ ("موارد بشرية داخل شركة", "موظف يراسل الموارد البشرية"),
217
+ ("وساطة عقارية", "باحث عن سكن أو مالك"),
218
+ ("تطبيق توصيل طعام", "زبون يتابع طلبه"),
219
+ ("استشارات قانونية", "شخص يسأل عن حقوقه"),
220
+ ("تصنيف مواضيع الأخبار", "قارئ يبحث عن خبر"),
221
+ ("مراجعة المحتوى والإبلاغ", "مستخدم يبلّغ عن محتوى"),
222
+ ("توجيه مساعد ذكي", "مستخدم يطلب من مساعد ذكي مهمة"),
223
+ ("إدارة مالية شخصية", "شخص يدير مصروفاته"),
224
+ ("شركة تأمين", "عميل يسأل عن وثيقته"),
225
+ ("صيانة منزلية", "ساكن يطلب صيانة"),
226
+ ("مطعم وحجز طاولات", "زبون يحجز أو يشتكي"),
227
+ ("صيدلية وطلب دواء", "مريض يسأل عن دواء"),
228
+ ("نادٍ رياضي واشتراكات", "مشترك في صالة رياضية"),
229
+ ("شحن ولوجستيات", "عميل يتابع شحنة"),
230
+ ("معرض وصيانة سيارات", "مالك سيارة"),
231
+ ("بنك استثماري ومحافظ", "مستثمر يسأل عن محفظته"),
232
+ ("سياحة دينية وحج وعمرة", "معتمر ينظّم رحلته"),
233
+ ("جامعة وقبول وتسجيل", "طالب جامعي"),
234
+ ("مكتب محاماة", "موكّل يتابع قضيته"),
235
+ ("زراعة وإرشاد زراعي", "مزارع يسأل عن محصوله"),
236
+ ("خدمة كهرباء ومياه", "مشترك يبلّغ عن انقطاع"),
237
+ ("تأجير سيارات", "مستأجر يحجز مركبة"),
238
+ ("فندق وضيافة", "نزيل يحجز أو يشتكي"),
239
+ ("منصة توظيف", "باحث عن عمل"),
240
+ ("متجر إلكترونيات وضمان", "مشترٍ يطالب بالضمان"),
241
+ ("خدمات بريدية", "مرسِل يتابع طردًا"),
242
+ ("مركز تدريب ودورات", "متدرب يسجّل في دورة"),
243
+ ("عيادة أسنان ومواعيد", "مريض يحجز موعدًا"),
244
+ ("شركة تنظيف منازل", "زبون يطلب خدمة"),
245
+ ("منصة بث ومحتوى", "مشترك يسأل عن اشتراكه"),
246
+ ("متجر ملابس ومقاسات", "زبون يستبدل قطعة"),
247
+ ("خدمة استضافة ونطاقات", "صاحب موقع"),
248
+ ("جمعية خيرية وتبرعات", "متبرع يسأل"),
249
+ ("نقل عام وتذاكر", "راكب يستفسر"),
250
+ ("مكتبة وبيع كتب", "قارئ يطلب كتابًا"),
251
+ ("خدمة محاسبة وضرائب", "صاحب منشأة"),
252
+ ("متجر حيوانات أليفة", "مربّي حيوان"),
253
+ ]
254
+
255
+ STYLES = {
256
+ "noun": ("كلمة واحدة أو كلمتين", "مثل: برمجة، فواتير، شكاوى"),
257
+ "phrase": ("عبارة قصيرة من ٢ إلى ٤ كلمات", "مثل: كتابة إبداعية، إلغاء الاشتراك"),
258
+ "desc": ("جملة وصفية كاملة", "مثل: أسئلة حول الفواتير والمدفوعات وطلبات الاسترجاع"),
259
+ }
260
+
261
+ DIFFICULTY = {
262
+ "easy": "اجعل الفئات متباعدة وواضحة الفروق بينها.",
263
+ "hard": ("اجعل بعض الفئات متقاربة جدًا في المعنى بحيث يصعب التمييز بينها دون قراءة النص "
264
+ "بعناية."),
265
+ }
266
+
267
+ # Mode 1: the model invents the lanes (topical / subdomain).
268
+ PROMPT_INVENT = """أنت تكتب بيانات تدريبية لنموذج يوجّه النصوص إلى فئات يحددها المستخدم.
269
+
270
+ المجال: {domain} ({hint})
271
+
272
+ الخطوة الأولى — اقترح {n} فئة للتوجيه في هذا المجال:
273
+ - اكتب كل فئة بأسلوب: {style_desc} ({style_ex}).
274
+ - {difficulty}
275
+ - الفئات يجب أن تغطي المجال دون تداخل كامل بينها.{other_note}
276
+
277
+ الخطوة الثانية — اكتب {k} نصوص مستقلة، كل نص من شخص حقيقي ({hint}).
278
+ النص رقم i يجب أن ينتمي إلى الفئة المطلوبة له في القائمة أدناه:
279
+ {targets}
280
+
281
+ {fmt}"""
282
+
283
+ # Mode 2/3: the lanes are given (single axis or mixed axes); the model only writes the texts.
284
+ PROMPT_GIVEN = """أنت تكتب بيانات تدريبية لنموذج يوجّه النصوص إلى فئات يحددها المستخدم.
285
+
286
+ الفئات المعطاة (استخدمها كما هي حرفيًا وبنفس الترتيب):
287
+ {lanes_block}
288
+
289
+ السياق: {hint}
290
+
291
+ اكتب {k} نصوص عربية مستقلة وواقعية. النص رقم i يجب أن ينتمي إلى الفئة المطلوبة له:
292
+ {targets}
293
+
294
+ {fmt}"""
295
+
296
+ FMT = """اكتب بهذا الشكل بالضبط:
297
+
298
+ {C}
299
+ (فئة في كل سطر، مسبوقة بـ "- "، بالترتيب وبنفس الصياغة في كل مرة)
300
+ {Q}
301
+ (النص العربي الأول، من سطر إلى ثلاثة أسطر)
302
+ {A}
303
+ (اسم الفئة التي ينتمي إليها هذا النص، منسوخًا حرفيًا من قائمة الفئات)
304
+ {Q}
305
+ (النص الثاني)
306
+ {A}
307
+ (فئته)
308
+ ... وهكذا حتى {k} نصوص
309
+ {E}
310
+
311
+ قواعد مهمة:
312
+ - لا تذكر اسم الفئة داخل النص نفسه. النص يجب أن يدل على فئته بالمعنى لا بالكلمة.
313
+ - اكتب نصوصًا طبيعية كما يكتبها الناس فعلًا، بأطوال مختلفة.
314
+ - نوّع الأسماء والتفاصيل والأسلوب بين النصوص.
315
+ - لا تكتب أي شيء خارج الوسوم."""
316
+
317
+
318
+ def _targets_block(rng, n_lanes, has_other):
319
+ real = list(range(n_lanes))
320
+ targets = [rng.choice(real) for _ in range(ITEMS_PER_TASK)]
321
+ if has_other:
322
+ targets[rng.randrange(ITEMS_PER_TASK)] = n_lanes
323
+ return targets, "\n".join(f"- النص {i+1}: الفئة رقم {t+1}"
324
+ for i, t in enumerate(targets))
325
+
326
+
327
+ def build_task(task_id: int, seed: int = 1234):
328
+ """task_id -> (axes, prompt). Deterministic, so a resumed run redraws identical prompts."""
329
+ rng = random.Random(seed * 7_000_003 + task_id)
330
+ mode = rng.choices(["mixed", "axis", "topical"], weights=[45, 35, 20])[0]
331
+ style = rng.choice(["noun", "phrase", "phrase", "desc"])
332
+ difficulty = "hard" if rng.random() < 0.4 else "easy"
333
+ has_other = rng.random() < 0.30 # v1 used 0.15; the reference uses a catch-all often
334
+ other_lane = rng.choice(OTHER_LANES)
335
+ fmt = FMT.format(C=C, Q=Q, A=A, E=E, k=ITEMS_PER_TASK)
336
+
337
+ if mode == "topical":
338
+ use_sub = rng.random() < 0.2
339
+ domain, hint = rng.choice(SUBDOMAIN_FAMILIES if use_sub else DOMAINS)
340
+ n = rng.choice([2, 3, 3, 4, 4, 4, 5, 5, 6, 7, 8])
341
+ sd, sx = STYLES[style]
342
+ other_note = (f"\n- أضف في نهاية القائمة فئة أخيرة اسمها بالضبط: {other_lane}"
343
+ "\n- واجعل أحد النصوص لا ينتمي لأي فئة إلا هذه الفئة الأخيرة.") \
344
+ if has_other else ""
345
+ targets, tgt = _targets_block(rng, n, has_other)
346
+ axes = {"mode": mode, "axis": "subdomain" if use_sub else "topical", "domain": domain,
347
+ "style": style, "difficulty": difficulty, "has_other": has_other,
348
+ # total lane count, Other included - the axis/mixed branch counts the same way
349
+ "n_routes": n + (1 if has_other else 0), "targets": targets}
350
+ return axes, PROMPT_INVENT.format(domain=domain, hint=hint, n=n, k=ITEMS_PER_TASK,
351
+ style_desc=sd, style_ex=sx,
352
+ difficulty=DIFFICULTY[difficulty],
353
+ other_note=other_note, targets=tgt, fmt=fmt)
354
+
355
+ if mode == "axis":
356
+ name = rng.choice(list(AXES))
357
+ spec = AXES[name]
358
+ k = min(len(spec["lanes"]), rng.choice([3, 3, 4, 4, 5, 5, 6, 7]))
359
+ picked = rng.sample(spec["lanes"], k)
360
+ lanes = [rng.choice(w) for w in picked] # one wording per lane, per task
361
+ hint = spec["hint"]
362
+ axis_label, domain = name, name
363
+ else: # mixed - compound lanes from one pool
364
+ pool_name = rng.choice(list(COMPOUND_POOLS))
365
+ hint, pool = COMPOUND_POOLS[pool_name]
366
+ k = min(len(pool), rng.choice([3, 4, 4, 5, 5, 6, 6, 7, 8]))
367
+ lanes = rng.sample(pool, k)
368
+ axis_label, domain = "compound:" + pool_name, pool_name
369
+
370
+ if has_other:
371
+ lanes = lanes + [other_lane]
372
+ rng.shuffle(lanes)
373
+ n = len(lanes) - (1 if has_other else 0)
374
+ other_idx = lanes.index(other_lane) if has_other else None
375
+ real_idx = [i for i in range(len(lanes)) if i != other_idx]
376
+ targets = [rng.choice(real_idx) for _ in range(ITEMS_PER_TASK)]
377
+ if has_other:
378
+ targets[rng.randrange(ITEMS_PER_TASK)] = other_idx
379
+ tgt = "\n".join(f"- النص {i+1}: الفئة رقم {t+1}" for i, t in enumerate(targets))
380
+ lanes_block = "\n".join(f"- {c}" for c in lanes)
381
+ axes = {"mode": mode, "axis": axis_label, "domain": domain, "style": style,
382
+ "difficulty": difficulty, "has_other": has_other,
383
+ "n_routes": len(lanes), "targets": targets}
384
+ return axes, PROMPT_GIVEN.format(lanes_block=lanes_block, hint=hint, k=ITEMS_PER_TASK,
385
+ targets=tgt, fmt=fmt)
386
+
387
+
388
+ # ---------------------------------------------------------------- parsing / validation
389
+ # Identical contract to router_common; the tag format and structural checks carry over unchanged.
390
+
391
+ BLOCK_RE = re.compile(re.escape(C) + r"(?P<cats>.*?)(?=" + re.escape(Q) + r")", re.S)
392
+ PAIR_RE = re.compile(re.escape(Q) + r"(?P<text>.*?)" + re.escape(A) + r"(?P<label>.*?)"
393
+ r"(?=" + re.escape(Q) + r"|" + re.escape(E) + r"|\Z)", re.S)
394
+
395
+
396
+ def norm(s: str) -> str:
397
+ s = unicodedata.normalize("NFKC", s or "")
398
+ s = re.sub(r"[ً-ْٰـ]", "", s)
399
+ return re.sub(r"\s+", " ", s).strip()
400
+
401
+
402
+ def parse_items(raw: str):
403
+ m = BLOCK_RE.search(raw or "")
404
+ if not m:
405
+ return []
406
+ routes = []
407
+ for line in m.group("cats").split("\n"):
408
+ line = line.strip()
409
+ c = re.match(r"^[-*•]\s*(.+)$", line) or re.match(r"^\d+[.)]\s*(.+)$", line)
410
+ if c:
411
+ r = norm(c.group(1)).strip(" .:،")
412
+ if 2 <= len(r) <= 120:
413
+ routes.append(r)
414
+ if not routes:
415
+ return []
416
+ out = []
417
+ for p in PAIR_RE.finditer(raw):
418
+ out.append({"routes": routes, "text": norm(p.group("text")),
419
+ "label_text": norm(p.group("label")).strip(" .:،").lstrip("- ")})
420
+ return out
421
+
422
+
423
+ def _match_route(label, routes):
424
+ if label in routes:
425
+ return routes.index(label)
426
+ hits = [i for i, r in enumerate(routes) if r and (r in label or label in r)]
427
+ return hits[0] if len(hits) == 1 else None
428
+
429
+
430
+ def validate(item, min_text=15, max_text=600):
431
+ """Structural only. Semantic correctness is decided by verify_router.py, not here."""
432
+ routes, text, label = item["routes"], item["text"], item["label_text"]
433
+ if not (2 <= len(routes) <= 9):
434
+ return False, "route_count"
435
+ if len(set(routes)) != len(routes):
436
+ return False, "duplicate_routes"
437
+ if not (min_text <= len(text) <= max_text):
438
+ return False, "text_length"
439
+ if any(t in text for t in (C, Q, A, E)):
440
+ return False, "tag_leak"
441
+ idx = _match_route(label, routes)
442
+ if idx is None:
443
+ return False, "label_not_in_routes"
444
+ if routes[idx] and routes[idx] in text:
445
+ return False, "lexical_giveaway"
446
+ if len(text.split()) < 4:
447
+ return False, "text_too_few_words"
448
+ return True, "ok"
449
+
450
+
451
+ def label_index(item):
452
+ return _match_route(item["label_text"], item["routes"])
453
+
454
+
455
+ def dedup_key(text: str) -> str:
456
+ return re.sub(r"\d+", "#", re.sub(r"\W+", "", norm(text)))
457
+
458
+
459
+ def lane_set_key(routes) -> str:
460
+ return "|".join(sorted(norm(r) for r in routes))