smolnikov commited on
Commit
2daebb5
·
verified ·
1 Parent(s): 43d476b

Migom 4B v0.1

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
NOTICE ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Migom (migom-4b)
2
+ Copyright 2026 Kirill Smolnikov / CapyAgent
3
+
4
+ This model is a fine-tuned derivative of:
5
+ - decider-4b (Mapika/decider-4b, revision eb5fbdfc), Apache License 2.0
6
+ - Qwen3.5-4B-Base (Qwen/Qwen3.5-4B-Base), Apache License 2.0
7
+ Modifications: LoRA fine-tuning (merged into the weights) on an open, commercially licensed
8
+ Russian/English typed-decision mix and synthetic skill-routing data (see README).
README.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ru
4
+ - en
5
+ license: apache-2.0
6
+ library_name: transformers
7
+ pipeline_tag: text-classification
8
+ tags:
9
+ - typed-decisions
10
+ - system-one
11
+ - russian
12
+ - routing
13
+ - agents
14
+ - decider
15
+ base_model: Mapika/decider-4b
16
+ datasets:
17
+ - smolnikov/RuDecide
18
+ ---
19
+
20
+ # Мигом 4B (migom-4b)
21
+
22
+ Собственная модель для быстрых решений, обученная нами на наших данных. Разработка Smolnikov / CapyAgent.
23
+ Старшая модель семейства. Младшие: [Мигом 2B](https://huggingface.co/smolnikov/migom-2b) и [Кивок 0.3B](https://huggingface.co/smolnikov/kivok-0.3b).
24
+ Работает локально, без облака: около 0,05 с на решение на ноутбучной RTX 5070 Ti (веса bf16 8,4 ГБ, нужна видеокарта от 12 ГБ).
25
+
26
+ Модель не пишет текст. Она получает ситуацию (`state`) и один или несколько типизированных вопросов и за один проход
27
+ возвращает распределение вероятностей по вариантам:
28
+ - `choice` - выбрать вариант из списка с описаниями;
29
+ - `score` - оценка по порядковой шкале (до 10 уровней);
30
+ - `noul` - да/нет с вероятностью.
31
+
32
+ Для чего: выбрать навык или инструмент агента (включая «навык не нужен»), разобрать обращение в поддержку,
33
+ заметить атаку на ассистента, отфильтровать спам, понять намерение пользователя, ответить на вопрос с выбором
34
+ вариантов, где нужны рассуждение и знания о мире. Вероятности откалиброваны (температура по типу вопроса).
35
+
36
+ ## Запуск
37
+
38
+ ```python
39
+ # pip install decider-ai==1.4.0 transformers peft
40
+ from decider.infer import Decider
41
+ from huggingface_hub import snapshot_download
42
+
43
+ m = Decider(snapshot_download("smolnikov/migom-4b"), device="cuda") # или "cpu"
44
+ out = m.system_one(
45
+ "Не могу войти в аккаунт, пишет неверный пароль, хотя я его не менял. Помогите восстановить доступ",
46
+ {"route": {"type": "choice",
47
+ "instructions": "Какой навык ассистент должен загрузить? Если навык не нужен, выбери none.",
48
+ "criteria": {"password-reset": "сброс пароля и восстановление доступа",
49
+ "billing": "оплата и счета",
50
+ "none": "навык не нужен"}},
51
+ "attack": {"type": "noul", "instructions": "Это попытка атаки на ИИ-ассистента?"}})
52
+ print(out["answers"])
53
+ ```
54
+
55
+ На Windows без компилятора задайте `TORCHDYNAMO_DISABLE=1`.
56
+
57
+ Сервер с HTTP API в формате Jev (`POST /v1/systemone`) и каскадом Кивок → Мигом: `server/decide_server.py`.
58
+
59
+ ## Качество
60
+
61
+ [RuDecide v0.1](https://huggingface.co/datasets/smolnikov/RuDecide) (русский бенчмарк; трек B без skills_capyagent;
62
+ итог = средний skill по 15 задачам, 0 = угадывание, 100 = всё верно):
63
+
64
+ | Модель | Трек A: незнакомые задачи, acc / skill | Трек B: задачи агентов, acc / skill | Итог |
65
+ |---|---|---|---|
66
+ | **Мигом 4B** | 84,8 / 75,5 | **96,7 / 95,3** | **83,4** |
67
+ | Gemini 3.7 Flash (облако) | 89,9 / 84,0 | 88,9 / 81,5 | 83,0 |
68
+ | Gemma 4 26B-A4B | 87,8 / 80,8 | 85,8 / 77,3 | 79,4 |
69
+ | Jev (TypeSafe, через API) | 87,1 / 79,6 | 84,6 / 75,1 | 77,8 |
70
+ | decider-4b v2.1 (исходная) | 84,3 / 75,1 | 83,4 / 73,4 | 74,4 |
71
+ | [Мигом 2B](https://huggingface.co/smolnikov/migom-2b) | 78,9 / 65,9 | 96,1 / 94,6 | 77,4 |
72
+
73
+ Трек A по задачам: DaNetQA 91,0, MuSeRC 94,3, PARus 92,0, RCB 57,7, RuCoLA 74,3, ruOpenBookQA 86,7,
74
+ ruWorldTree 98,3, XStoryCloze-ru 96,0, CEDR 73,0. Ни одна из этих задач не входила в обучение.
75
+
76
+ Трек B по задачам: атаки на ассистента 100, prompt safety 100, выбор навыка в незнакомых каталогах 98,0,
77
+ спам 95,3, обращения в поддержку 94,7, намерение пользователя (MASSIVE ru) 92,0. Калибровка: ECE 0,012.
78
+
79
+ Честно об ограничениях: трек B собран из тех же открытых источников, что и обучение (тестовые примеры отложены),
80
+ поэтому там у Мигома домашнее поле. На незнакомых задачах с рассуж��ением (трек A) Мигом 4B отстаёт от Jev на 2,3 пункта
81
+ точности. Длинные контексты (больше ~1 500 токенов) в дообучении не участвовали. Жалобу без явной просьбы
82
+ («не могу войти, пишет неверный пароль») модель может отнести к «навык не нужен»: опирайтесь на вероятности.
83
+
84
+ ## Данные и обучение
85
+
86
+ Только открытые данные, лицензии которых разрешают коммерческое использование, и синтетика от открытой модели.
87
+ Без личных данных и без ответов закрытых коммерческих моделей (ответы Jev использовались только для сравнения).
88
+
89
+ 22 620 примеров, 1 эпоха, LoRA rank 32 (alpha 64) на attention и MLP, LR 1e-4, fp16 на T4 (Kaggle), влита в веса bf16.
90
+ Источники: LocalLLaMA/typed-decisions, tasksource/procedural-typed-decisions (Apache-2.0); MASSIVE ru (CC BY 4.0);
91
+ TERRa (Russian SuperGLUE, MIT); ai-forever headline / ru-reviews / kinopoisk (MIT, Apache-2.0);
92
+ A11Sunday/support-json-ru, nvidia/When2Call (CC BY 4.0); Mapika/decider teacher data (Apache-2.0);
93
+ DmitryKRX/anti_spam_ru, dmtrdr/russian_prompt_injections (Apache-2.0); Nailyk14/prompt-safety-multilingual
94
+ (только строки с Apache/MIT); синтетика выбора навыка по 40 выдуманным каталогам и короткие диалоги,
95
+ сгенерированные DeepSeek (MIT). Скрипты: папка `training/` (`train_decider_lora.py` - само дообучение).
96
+
97
+ ## Лицензия
98
+
99
+ Apache-2.0. Модель дообучена из [decider-4b](https://huggingface.co/Mapika/decider-4b) (Apache-2.0) на основе
100
+ [Qwen3.5-4B-Base](https://huggingface.co/Qwen/Qwen3.5-4B-Base) (Apache-2.0), см. `NOTICE`.
chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is false %}
150
+ {{- '<think>\n\n</think>\n\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForCausalLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "attn_output_gate": true,
8
+ "bos_token_id": null,
9
+ "dtype": "bfloat16",
10
+ "eos_token_id": 248044,
11
+ "full_attention_interval": 4,
12
+ "head_dim": 256,
13
+ "hidden_act": "silu",
14
+ "hidden_size": 2560,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 9216,
17
+ "layer_types": [
18
+ "linear_attention",
19
+ "linear_attention",
20
+ "linear_attention",
21
+ "full_attention",
22
+ "linear_attention",
23
+ "linear_attention",
24
+ "linear_attention",
25
+ "full_attention",
26
+ "linear_attention",
27
+ "linear_attention",
28
+ "linear_attention",
29
+ "full_attention",
30
+ "linear_attention",
31
+ "linear_attention",
32
+ "linear_attention",
33
+ "full_attention",
34
+ "linear_attention",
35
+ "linear_attention",
36
+ "linear_attention",
37
+ "full_attention",
38
+ "linear_attention",
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "full_attention",
42
+ "linear_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "full_attention",
46
+ "linear_attention",
47
+ "linear_attention",
48
+ "linear_attention",
49
+ "full_attention"
50
+ ],
51
+ "linear_conv_kernel_dim": 4,
52
+ "linear_key_head_dim": 128,
53
+ "linear_num_key_heads": 16,
54
+ "linear_num_value_heads": 32,
55
+ "linear_value_head_dim": 128,
56
+ "mamba_ssm_dtype": "float32",
57
+ "max_position_embeddings": 262144,
58
+ "mlp_only_layers": [],
59
+ "model_type": "qwen3_5_text",
60
+ "mtp_num_hidden_layers": 1,
61
+ "mtp_use_dedicated_embeddings": false,
62
+ "num_attention_heads": 16,
63
+ "num_hidden_layers": 32,
64
+ "num_key_value_heads": 4,
65
+ "pad_token_id": null,
66
+ "partial_rotary_factor": 0.25,
67
+ "rms_norm_eps": 1e-06,
68
+ "rope_parameters": {
69
+ "mrope_interleaved": true,
70
+ "mrope_section": [
71
+ 11,
72
+ 11,
73
+ 10
74
+ ],
75
+ "partial_rotary_factor": 0.25,
76
+ "rope_theta": 10000000,
77
+ "rope_type": "default"
78
+ },
79
+ "tie_word_embeddings": true,
80
+ "transformers_version": "5.17.0",
81
+ "use_cache": true,
82
+ "vocab_size": 248320
83
+ }
decider_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "temperature": 1.099,
3
+ "temperature_by_type": {
4
+ "choice": 1.11,
5
+ "noul": 1.56,
6
+ "score": 1.287
7
+ },
8
+ "neutralize_none": false,
9
+ "version": "migom-4b-0.1",
10
+ "base": "Mapika/decider-4b v1 + LoRA (merged); v1 is Qwen/Qwen3.5-4B-Base + one supervised pass over mixture v2",
11
+ "layout": "plain",
12
+ "max_options": 255,
13
+ "max_state_tokens": 32768,
14
+ "schema_first": false,
15
+ "schema_first_trained": false,
16
+ "isolated_levels": true,
17
+ "release_date": "2026-09-24",
18
+ "requires": "decider-ai>=1.4.0 for temperature_by_type; older versions serve every answer at temperature",
19
+ "stage": "LoRA r32 (alpha 64) on attention+MLP, 1 epoch over 22,620 rows of an open commercially licensed Russian/English typed-decision mix, merged into bf16 weights",
20
+ "parent": "4b-v2.1"
21
+ }
generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "eos_token_id": 248044,
4
+ "transformers_version": "5.17.0",
5
+ "use_cache": true
6
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:582a60cb01882f7ea6e4e227f10148f620cd73a40442a5c7941422d3527f52fb
3
+ size 8411558400
server/decide_server.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Open typed-decision server with a Jev-compatible API.
2
+
3
+ POST /v1/systemone {"state": ..., "questions": {id: {"type": "choice|score|noul", "instructions": ..., "criteria": ...}},
4
+ "model": "kivok" | "migom" | "auto"}
5
+ -> {"model": ..., "answers": {id: {...TypeSafe answer shape...}}, "usage": {...}}
6
+ GET /v1/models
7
+
8
+ Models:
9
+ kivok - small (0.3B) and fast, runs on CPU;
10
+ migom - larger (2B), better at reasoning; GPU recommended;
11
+ auto - cascade: Kivok answers, questions where its top probability is below AUTO_THRESHOLD go to Migom.
12
+
13
+ Env: KIVOK_PATH, MIGOM_PATH (either may be empty), DEVICE (cpu|cuda|xpu), DTYPE (fp16|bf16|fp32; use fp16 on Intel XPU),
14
+ GRAPHS (0 disables graphs), AUTO_THRESHOLD (default 0.8),
15
+ API_KEY (if set, require "Authorization: Bearer <key>"), HOST (default 127.0.0.1), PORT (default 8010).
16
+ """
17
+ import hmac, os, threading, time
18
+ from typing import Any, Dict, Optional
19
+ from fastapi import FastAPI, HTTPException, Request
20
+ from fastapi.responses import JSONResponse
21
+ from pydantic import BaseModel
22
+
23
+ KIVOK_PATH = os.environ.get('KIVOK_PATH', '')
24
+ MIGOM_PATH = os.environ.get('MIGOM_PATH', '')
25
+ DEVICE = os.environ.get('DEVICE') or None
26
+ THRESHOLD = float(os.environ.get('AUTO_THRESHOLD', '0.8'))
27
+ API_KEY = os.environ.get('API_KEY', '')
28
+ DTYPE = os.environ.get('DTYPE', '') # fp16 on Intel XPU: bf16 gives wrong answers there
29
+ GRAPHS = os.environ.get('GRAPHS', '') # '0' disables CUDA/XPU graphs
30
+
31
+ app = FastAPI(title='typed-decision server', version='0.1')
32
+ _lock = threading.Lock()
33
+ _models: Dict[str, Any] = {}
34
+
35
+
36
+ def kivok():
37
+ if 'kivok' not in _models:
38
+ if not KIVOK_PATH:
39
+ raise HTTPException(503, 'kivok is not configured (KIVOK_PATH)')
40
+ import laya
41
+ _models['kivok'] = laya.load(KIVOK_PATH, device=DEVICE) if DEVICE else laya.load(KIVOK_PATH)
42
+ return _models['kivok']
43
+
44
+
45
+ def migom():
46
+ if 'migom' not in _models:
47
+ if not MIGOM_PATH:
48
+ raise HTTPException(503, 'migom is not configured (MIGOM_PATH)')
49
+ from decider.infer import Decider
50
+ import torch
51
+ dt = {'fp16': torch.float16, 'bf16': torch.bfloat16, 'fp32': torch.float32}.get(DTYPE)
52
+ _models['migom'] = Decider(MIGOM_PATH, device=DEVICE, dtype=dt, use_graphs=False if GRAPHS == '0' else None)
53
+ return _models['migom']
54
+
55
+
56
+ def top_prob(ans: dict) -> float:
57
+ if 'probabilities' in ans and ans['probabilities']:
58
+ return max(ans['probabilities'].values())
59
+ if 'noul' in ans:
60
+ p = float(ans['noul']); return max(p, 1 - p)
61
+ return 1.0
62
+
63
+
64
+ def ask_kivok(state, questions):
65
+ return kivok().predict(state, questions)
66
+
67
+
68
+ def ask_migom(state, questions):
69
+ return migom().system_one(state, questions)
70
+
71
+
72
+ class Request_(BaseModel):
73
+ state: Any
74
+ questions: Dict[str, Dict[str, Any]]
75
+ model: Optional[str] = 'auto'
76
+
77
+
78
+ @app.middleware('http')
79
+ async def auth(request: Request, call_next):
80
+ if API_KEY and request.url.path.startswith('/v1') and not hmac.compare_digest(
81
+ request.headers.get('authorization', ''), f'Bearer {API_KEY}'):
82
+ return JSONResponse({'detail': 'missing or invalid API key'}, 401)
83
+ return await call_next(request)
84
+
85
+
86
+ @app.get('/v1/models')
87
+ def models():
88
+ return {'data': [{'id': m, 'configured': bool(p)} for m, p in
89
+ (('kivok', KIVOK_PATH), ('migom', MIGOM_PATH), ('auto', KIVOK_PATH or MIGOM_PATH))]}
90
+
91
+
92
+ @app.post('/v1/systemone')
93
+ def systemone(req: Request_):
94
+ if not req.questions:
95
+ raise HTTPException(422, 'questions must not be empty')
96
+ name = (req.model or 'auto').lower()
97
+ t0 = time.time()
98
+ with _lock:
99
+ try:
100
+ if name == 'kivok' or (name == 'auto' and not MIGOM_PATH):
101
+ out = ask_kivok(req.state, req.questions); used = {k: 'kivok' for k in req.questions}
102
+ elif name == 'migom' or (name == 'auto' and not KIVOK_PATH):
103
+ out = ask_migom(req.state, req.questions); used = {k: 'migom' for k in req.questions}
104
+ elif name == 'auto':
105
+ out = ask_kivok(req.state, req.questions)
106
+ used = {k: 'kivok' for k in req.questions}
107
+ hard = {k: q for k, q in req.questions.items() if top_prob(out['answers'][k]) < THRESHOLD}
108
+ if hard:
109
+ second = ask_migom(req.state, hard)
110
+ for k in hard:
111
+ out['answers'][k] = second['answers'][k]; used[k] = 'migom'
112
+ else:
113
+ raise HTTPException(404, f'unknown model {name!r}')
114
+ except ValueError as e:
115
+ raise HTTPException(422, str(e))
116
+ return {'model': name, 'answers': out['answers'], 'routed': used,
117
+ 'usage': {'latency_ms': round(1000 * (time.time() - t0), 1)}}
118
+
119
+
120
+ if __name__ == '__main__':
121
+ import uvicorn
122
+ uvicorn.run(app, host=os.environ.get('HOST', '127.0.0.1'), port=int(os.environ.get('PORT', '8010')))
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523
3
+ size 19989325
tokenizer_config.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|endoftext|>",
10
+ "errors": "replace",
11
+ "image_token": "<|image_pad|>",
12
+ "is_local": true,
13
+ "local_files_only": false,
14
+ "model_max_length": 262144,
15
+ "model_specific_special_tokens": {
16
+ "audio_bos_token": "<|audio_start|>",
17
+ "audio_eos_token": "<|audio_end|>",
18
+ "audio_token": "<|audio_pad|>",
19
+ "image_token": "<|image_pad|>",
20
+ "video_token": "<|video_pad|>",
21
+ "vision_bos_token": "<|vision_start|>",
22
+ "vision_eos_token": "<|vision_end|>"
23
+ },
24
+ "pad_token": "<|endoftext|>",
25
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
26
+ "split_special_tokens": false,
27
+ "tokenizer_class": "Qwen2Tokenizer",
28
+ "unk_token": null,
29
+ "video_token": "<|video_pad|>",
30
+ "vision_bos_token": "<|vision_start|>",
31
+ "vision_eos_token": "<|vision_end|>"
32
+ }
training/bench.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process zero-shot benchmark of Jev-like models on our skill-routing test set.
2
+ python bench.py {jevk5|decider} MODEL_DIR data/test.jsonl out.json [--limit N]"""
3
+ import json, sys, time, argparse, os, collections
4
+ os.environ.setdefault('HF_HUB_OFFLINE', '1')
5
+ sys.stdout.reconfigure(encoding='utf-8')
6
+ ap = argparse.ArgumentParser(); ap.add_argument('backend'); ap.add_argument('model'); ap.add_argument('data'); ap.add_argument('out')
7
+ ap.add_argument('--limit', type=int, default=0); ap.add_argument('--device', default='xpu'); ap.add_argument('--dtype', default=''); ap.add_argument('--no-graphs', action='store_true')
8
+ ap.add_argument('--ins', default="Which skill should the assistant load to handle this user message? Choose none if no special skill is needed.")
9
+ a = ap.parse_args()
10
+ if a.backend == 'jevk5':
11
+ from jevk5.runtime import JevK5
12
+ m = JevK5(a.model, device=a.device, graphs=False)
13
+ def probs(state, q): return m.probabilities(state, q)[0]
14
+ elif a.backend == 'decider':
15
+ from decider.infer import Decider
16
+ import torch
17
+ m = Decider(a.model, device=a.device, dtype={'fp16': torch.float16, 'bf16': torch.bfloat16}.get(a.dtype),
18
+ use_graphs=False if a.no_graphs else None)
19
+ def probs(state, q):
20
+ ans = m.system_one(state, {'x': q})['answers']['x']
21
+ if 'probabilities' in ans: return {str(k): v for k, v in ans['probabilities'].items()}
22
+ p = float(ans.get('noul', 0.5)); return {'true': p, 'false': 1 - p}
23
+ rows = [json.loads(l) for l in open(a.data, encoding='utf-8')]
24
+ rows = rows[:a.limit] if a.limit else rows
25
+ skipped = 0
26
+ res = []; lat = []; bins = [[0, 0, 0] for _ in range(10)]; ok = po = no = npos = nn = 0
27
+ for i, r in enumerate(rows):
28
+ t = r['q'].get('t', 'choice')
29
+ q = {'type': t, 'instructions': a.ins if t == 'choice' and 'source' not in r else r['q']['ins'], 'criteria': r['q']['crit']}
30
+ t = time.time()
31
+ try:
32
+ p = probs(r['state'][:6000], q)
33
+ except ValueError as e:
34
+ skipped += 1; continue
35
+ lat.append(time.time() - t)
36
+ pred = max(p, key=p.get); c = p[pred]; g = pred == r['gold']; ok += g
37
+ if r['gold'] == 'none': nn += 1; no += g
38
+ else: npos += 1; po += g
39
+ b = min(9, int(c * 10)); bins[b][0] += 1; bins[b][1] += c; bins[b][2] += g
40
+ res.append({'gold': r['gold'], 'pred': pred, 'conf': c, 'src': r.get('source', 'unknown'), 'k': len(r.get('opts') or r['q']['crit'] or [0, 0])})
41
+ if (i + 1) % 50 == 0: print(i + 1, 'acc', round(ok / len(res), 3), 'lat', round(sorted(lat)[len(lat)//2], 3), flush=True)
42
+ n = len(res)
43
+ mt = {'model': os.path.basename(a.model.rstrip('\\/')), 'n': n, 'acc': round(ok / n, 4), 'acc_skill': round(po / max(1, npos), 4),
44
+ 'acc_none': round(no / max(1, nn), 4), 'ece': round(sum(abs(b[1] - b[2]) for b in bins if b[0]) / n, 4),
45
+ 'lat_median_s': round(sorted(lat)[n // 2], 3)}
46
+ bs = collections.defaultdict(list)
47
+ for x in res: bs[x['src']].append(x['gold'] == x['pred'])
48
+ mt['skipped'] = skipped
49
+ mt['by_src'] = {k: round(sum(v) / len(v), 3) for k, v in sorted(bs.items())}
50
+ print('RESULT', json.dumps(mt)); json.dump({'metrics': mt, 'rows': res}, open(a.out, 'w'))
training/build_public.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the public (commercially clean) typed-decision mix for Kivok/Migom.
2
+
3
+ Every source below was license-checked 25.09.2026 (Apache/MIT/CC-BY/CC0). No personal data,
4
+ no outputs of closed commercial models. Output records:
5
+ {"state", "q": {"t", "ins", "crit"}, "opts": [labels in render order], "target": [...],
6
+ "gold": label, "source"}
7
+ Usage (WSL venv with `datasets`): python build_public.py OUT_DIR
8
+ """
9
+ import json, random, sys, os, urllib.request, collections
10
+ from datasets import load_dataset, get_dataset_config_names
11
+
12
+ OUT = sys.argv[1]
13
+ rng = random.Random(20260925)
14
+ MAX_OPTS = 20
15
+ CAP_TRAIN = {'*': 3000, 'support_ru': 4000, 'typed_decisions': 4000}
16
+ recs = collections.defaultdict(list) # split -> records
17
+ stats = collections.Counter()
18
+
19
+
20
+ def add(split, source, state, t, ins, crit, target, gold=None):
21
+ if t == 'choice':
22
+ opts = list(crit)
23
+ elif t == 'score':
24
+ opts = [str(i) for i in range(len(crit))]
25
+ else:
26
+ opts = ['false', 'true']
27
+ if not (2 <= len(opts) <= MAX_OPTS) or len(target) != len(opts):
28
+ return
29
+ s = sum(target)
30
+ if s <= 0:
31
+ return
32
+ target = [v / s for v in target]
33
+ if gold is None:
34
+ gold = opts[max(range(len(target)), key=target.__getitem__)]
35
+ if isinstance(state, (dict, list)):
36
+ state = json.dumps(state, ensure_ascii=False)
37
+ state = str(state)[:6000]
38
+ if not state.strip():
39
+ return
40
+ recs[split].append({'state': state, 'q': {'t': t, 'ins': ins, 'crit': crit}, 'opts': opts,
41
+ 'target': target, 'gold': str(gold), 'source': source})
42
+ stats[(split, source)] += 1
43
+
44
+
45
+ def onehot(opts, gold):
46
+ return [1.0 if o == gold else 0.0 for o in opts]
47
+
48
+
49
+ def sample(ds, n):
50
+ idx = list(range(len(ds)))
51
+ rng.shuffle(idx)
52
+ return ds.select(idx[:n]) if n < len(idx) else ds
53
+
54
+
55
+ def jload(x):
56
+ return json.loads(x) if isinstance(x, str) else x
57
+
58
+
59
+ # 1. LocalLLaMA/typed-decisions (apache-2.0): native format, soft gold.
60
+ def typed_decisions():
61
+ for split, out in (('train', 'train'), ('test', 'test')):
62
+ ds = load_dataset('LocalLLaMA/typed-decisions', 'all', split=split)
63
+ for r in ds:
64
+ qs, gold = jload(r['questions']), jload(r['gold'])
65
+ for qid, q in qs.items():
66
+ g = gold.get(qid)
67
+ if not g:
68
+ continue
69
+ t, crit, p = q['type'], q.get('criteria'), g.get('probabilities', {})
70
+ if t == 'choice':
71
+ tgt = [p.get(k, 0.0) for k in crit]
72
+ elif t == 'score':
73
+ tgt = [p.get(str(i), 0.0) for i in range(len(crit))]
74
+ else:
75
+ tgt = [p.get('false', 0.5), p.get('true', 0.5)]
76
+ crit = crit if isinstance(crit, dict) and set(crit) == {'false', 'true'} else None
77
+ add(out, 'typed_decisions', r['state'], t, q['instructions'], crit, tgt)
78
+
79
+
80
+ # 2. tasksource/procedural-typed-decisions (apache-2.0): rule-computed labels over structured state.
81
+ def procedural(per_cfg_train=600, per_cfg_test=40):
82
+ for cfg in get_dataset_config_names('tasksource/procedural-typed-decisions'):
83
+ for split, out, n in (('train', 'train', per_cfg_train), ('test', 'test', per_cfg_test)):
84
+ try:
85
+ ds = sample(load_dataset('tasksource/procedural-typed-decisions', cfg, split=split), n)
86
+ except Exception as e:
87
+ print('skip', cfg, split, e); continue
88
+ for r in ds:
89
+ qs, ans = jload(r['questions']), jload(r['answers'])
90
+ qid = rng.choice(list(qs)) # one question per state keeps the mix balanced
91
+ q, a = qs[qid], ans.get(qid)
92
+ if not a:
93
+ continue
94
+ t, crit = q['type'], q.get('criteria')
95
+ if t == 'noul':
96
+ p = float(a.get('noul', 0.5)); tgt = [1 - p, p]; crit = None
97
+ elif t == 'score':
98
+ pr = a.get('probabilities', {}); tgt = [pr.get(str(i), 0.0) for i in range(len(crit))]
99
+ else:
100
+ pr = a.get('probabilities', {}); crit = crit if isinstance(crit, dict) else dict.fromkeys(crit)
101
+ tgt = [pr.get(k, 0.0) for k in crit]
102
+ add(out, 'procedural', r['state'], t, q['instructions'], crit, tgt)
103
+
104
+
105
+ # 3. MASSIVE ru (cc-by-4.0): scenario choice, then intent within the scenario.
106
+ def massive():
107
+ for split, out, n in (('train', 'train', 5000), ('validation', 'calib', 300), ('test', 'test', 400)):
108
+ ds = sample(load_dataset('mteb/MassiveIntentClassification', 'ru', split=split), n)
109
+ full = load_dataset('mteb/MassiveIntentClassification', 'ru', split='train')
110
+ intents = sorted(set(full['label']))
111
+ scen = sorted({i.split('_')[0] for i in intents})
112
+ by_scen = collections.defaultdict(list)
113
+ for i in intents:
114
+ by_scen[i.split('_')[0]].append(i)
115
+ for k, r in enumerate(ds):
116
+ sc = r['label'].split('_')[0]
117
+ if k % 2 == 0:
118
+ crit = {s: None for s in scen}
119
+ add(out, 'massive_ru_scenario', r['text'], 'choice',
120
+ 'К какой области относится запрос пользователя к голосовому ассистенту?', crit, onehot(list(crit), sc))
121
+ else:
122
+ opts = by_scen[sc]
123
+ if len(opts) < 2:
124
+ continue
125
+ crit = {i: i.split('_', 1)[1].replace('_', ' ') for i in opts}
126
+ add(out, 'massive_ru_intent', r['text'], 'choice',
127
+ 'Какое действие просит выполнить пользователь?', crit, onehot(opts, r['label']))
128
+
129
+
130
+ # 4. TERRa (Russian SuperGLUE, MIT): textual entailment -> noul.
131
+ def terra():
132
+ for split, out, n in (('train', 'train', 3000), ('dev', 'test', 300)):
133
+ ds = sample(load_dataset('mteb/TERRa', split=split), n)
134
+ for r in ds:
135
+ p = 1.0 if int(r['labels']) == 1 else 0.0
136
+ add(out, 'terra_ru', f"Текст: {r['sentence1']}\nУтверждение: {r['sentence2']}", 'noul',
137
+ 'Следует ли утверждение из текста?', None, [1 - p, p])
138
+
139
+
140
+ # 5. ai-forever classification (MIT/apache): headline topic (choice), review sentiment (score).
141
+ def ai_forever():
142
+ ds = load_dataset('ai-forever/headline-classification')
143
+ labels = sorted(set(ds['train']['label_text']))
144
+ for split, out, n in (('train', 'train', 3000), ('validation', 'calib', 150), ('test', 'test', 300)):
145
+ for r in sample(ds[split], n):
146
+ add(out, 'headline_ru', r['text'], 'choice', 'Какая тема у новостного заголовка?',
147
+ {l: None for l in labels}, onehot(labels, r['label_text']))
148
+ for name, src, n_tr in (('ai-forever/ru-reviews-classification', 'reviews_ru', 3000),
149
+ ('ai-forever/kinopoisk-sentiment-classification', 'kinopoisk_ru', 2000)):
150
+ ds = load_dataset(name)
151
+ names = {}
152
+ for r in ds['train']:
153
+ names[int(r['label'])] = r['label_text']
154
+ levels = [f'{names[i]}' for i in sorted(names)]
155
+ for split, out, n in (('train', 'train', n_tr), ('validation', 'calib', 100), ('test', 'test', 250)):
156
+ for r in sample(ds[split], n):
157
+ tgt = onehot(list(range(len(levels))), int(r['label']))
158
+ add(out, src, r['text'], 'score', 'Насколько положителен отзыв?', levels, tgt)
159
+
160
+
161
+ # 6. A11Sunday/support-json-ru (cc-by-4.0): Russian support triage, several questions per ticket.
162
+ SUP_CAT = {'billing': 'оплата, счета, списания', 'cancellation': 'отмена подписки или услуги',
163
+ 'refund': 'возврат денег', 'product': 'вопросы о продукте и тарифах',
164
+ 'integration': 'интеграции и API', 'access': 'доступ к аккаунту, вход',
165
+ 'technical': 'технические сбои и ошибки'}
166
+ SUP_ACT = {'answer': 'ответить самому', 'ask_details': 'уточнить недостающие данные',
167
+ 'propose_action': 'предложить действие по правилам', 'escalate': 'передать человеку'}
168
+ PRIO = ['low', 'medium', 'high', 'critical']
169
+ SENT = ['negative', 'neutral', 'positive']
170
+
171
+
172
+ def support():
173
+ for cfg, split, out in (('expanded_trained', 'train', 'train'), ('selected', 'validation', 'calib'), ('selected', 'test', 'test')):
174
+ for r in load_dataset('A11Sunday/support-json-ru', cfg, split=split):
175
+ pol = '\n'.join(p['text'] for p in jload(r['policies_json']))
176
+ facts = '; '.join(f"{f['key']}={f['value']}" for f in jload(r['facts_json']))
177
+ st = f"Обращение: {r['message']}\nПравила: {pol}\nФакты: {facts or 'нет'}"
178
+ add(out, 'support_ru', st, 'choice', 'К какой категории относится обращение?', SUP_CAT, onehot(list(SUP_CAT), r['category']))
179
+ add(out, 'support_ru', st, 'choice', 'Что должна сделать поддержка по правилам?', SUP_ACT, onehot(list(SUP_ACT), r['action']))
180
+ if r['priority'] in PRIO:
181
+ add(out, 'support_ru', st, 'score', 'Какой приоритет у обращения?',
182
+ ['низкий', 'средний', 'высокий', 'критический'], onehot(PRIO, r['priority']))
183
+ if r['sentiment'] in SENT:
184
+ add(out, 'support_ru', st, 'score', 'Какой тон у клиента?', ['негативный', 'нейтральный', 'позитивный'], onehot(SENT, r['sentiment']))
185
+ e = 1.0 if r['human_escalation'] else 0.0
186
+ add(out, 'support_ru', st, 'noul', 'Нужно ли передать обращение человеку?', None, [1 - e, e])
187
+
188
+
189
+ # 7. nvidia/When2Call (cc-by-4.0): should the assistant call a tool right now?
190
+ def when2call(n_train=3000, n_test=300):
191
+ ds = load_dataset('nvidia/When2Call', 'train_pref', split='train')
192
+ idx = list(range(len(ds))); rng.shuffle(idx)
193
+ for k, i in enumerate(idx[:n_train + n_test]):
194
+ r = ds[i]
195
+ raw = r['tools']
196
+ raw = eval(raw) if isinstance(raw, str) else (raw or [])
197
+ tools = [json.loads(t) if isinstance(t, str) else t for t in raw]
198
+ msgs = eval(r['messages']) if isinstance(r['messages'], str) else r['messages']; user = [m['content'] for m in msgs if m['role'] == 'user'][-1]
199
+ ch = r['chosen_response']
200
+ chosen = (eval(ch) if isinstance(ch, str) else ch)['content']
201
+ tl = '\n'.join(f"- {t['name']}: {t.get('description', '')[:150]}" for t in tools[:8])
202
+ st = f"User request: {user}\nAvailable tools:\n{tl or '(none)'}"
203
+ p = 1.0 if '<TOOLCALL>' in chosen else 0.0
204
+ add('train' if k < n_train else 'test', 'when2call', st, 'noul',
205
+ 'Should the assistant call one of the available tools now (instead of answering, asking or declining)?', None, [1 - p, p])
206
+
207
+
208
+ # 8. Mapika/decider teacher data (Apache-2.0, teacher Qwen3.5-27B): shell risk + routing. Keep teacher-consistent rows.
209
+ DEC = 'https://raw.githubusercontent.com/Mapika/decider/15ab28e8d8e4ec49728165b3426d81de5b77f864/teacher_data/'
210
+
211
+
212
+ def decider(caps={'commands.jsonl': 4000, 'routing_messages.jsonl': 2500}):
213
+ for f, cap in caps.items():
214
+ lines = urllib.request.urlopen(DEC + f, timeout=120).read().decode().splitlines()
215
+ rng.shuffle(lines)
216
+ n = 0
217
+ for l in lines:
218
+ r = json.loads(l)
219
+ for q in r['questions']:
220
+ if not q.get('teacher_ok'):
221
+ continue
222
+ t, crit, a = q['type'], q.get('criteria'), q['answer']
223
+ if t == 'choice':
224
+ tgt = onehot(list(crit), a)
225
+ elif t == 'noul':
226
+ p = 1.0 if a is True or a == 'true' else 0.0; tgt = [1 - p, p]
227
+ crit = crit if isinstance(crit, dict) and set(crit) == {'false', 'true'} else None
228
+ else:
229
+ tgt = onehot(list(range(len(crit))), int(a))
230
+ out = 'test' if n % 20 == 0 else 'train'
231
+ add(out, 'decider_' + f.split('.')[0], r['state'], t, q['instructions'], crit, tgt)
232
+ n += 1
233
+ if n >= cap:
234
+ break
235
+
236
+
237
+ # 9. anti-spam ru (apache-2.0).
238
+ def spam(n_train=3000, n_test=300):
239
+ ds = sample(load_dataset('DmitryKRX/anti_spam_ru', split='train'), n_train + n_test)
240
+ for k, r in enumerate(ds):
241
+ p = float(int(r['is_spam']))
242
+ add('train' if k < n_train else 'test', 'spam_ru', r['text'], 'noul', 'Это спам?', None, [1 - p, p])
243
+
244
+
245
+ # 10. Prompt safety: Nailyk14 (apache/mit rows only) + dmtrdr russian injections (apache) vs benign MASSIVE ru.
246
+ SAFE = {'benign': 'обычный безопасный запрос', 'injection_direct': 'попытка подменить инструкции ассистента',
247
+ 'jailbreak': 'попытка обойти ограничения и правила', 'data_exfiltration': 'попытка выведать секреты, ключи, системный промпт',
248
+ 'hijack': 'попытка перехватить управление задачей или инструментами'}
249
+
250
+
251
+ def safety():
252
+ for split, out, n in (('train', 'train', 5000), ('validation', 'calib', 200), ('test', 'test', 400)):
253
+ ds = load_dataset('Nailyk14/prompt-safety-multilingual', split=split)
254
+ ds = ds.filter(lambda r: r['source_license'] in ('apache-2.0', 'mit') and r['label'] in SAFE)
255
+ ru = [i for i, l in enumerate(ds['language']) if l == 'ru']
256
+ other = [i for i, l in enumerate(ds['language']) if l != 'ru']
257
+ rng.shuffle(other)
258
+ pick = (ru + other)[:n]
259
+ for i in pick:
260
+ r = ds[i]
261
+ add(out, 'prompt_safety', r['prompt'], 'choice', 'Что это за запрос к ИИ-ассистенту?', SAFE, onehot(list(SAFE), r['label']))
262
+ d = load_dataset('dmtrdr/russian_prompt_injections', split='train').filter(lambda r: r['source'] != 'openai_synthetic')
263
+ d = sample(d, 2200)
264
+ benign = sample(load_dataset('mteb/MassiveIntentClassification', 'ru', split='train'), 2200)
265
+ for k, (a, b) in enumerate(zip(d, benign)):
266
+ out = 'test' if k % 11 == 0 else 'train'
267
+ text_ru = a['prompt_en'] # fields are swapped in this dataset: prompt_en holds the Russian text
268
+ add(out, 'injection_ru', text_ru, 'noul', 'Это попытка атаки на ИИ-ассистента (обход правил, подмена инструкций)?', None, [0.0, 1.0])
269
+ add(out, 'injection_ru', b['text'], 'noul', 'Это попытка атаки на ИИ-ассистента (обход правил, подмена инструкций)?', None, [1.0, 0.0])
270
+
271
+
272
+ if __name__ == '__main__':
273
+ os.makedirs(OUT, exist_ok=True)
274
+ for fn in (typed_decisions, procedural, massive, terra, ai_forever, support, when2call, decider, spam, safety):
275
+ try:
276
+ fn(); print('ok', fn.__name__, flush=True)
277
+ except Exception as e:
278
+ print('FAILED', fn.__name__, repr(e)[:300], flush=True)
279
+ # calib: sources without their own validation split donate 8% of train
280
+ have_calib = {r['source'] for r in recs['calib']}
281
+ keep = []
282
+ for r in recs['train']:
283
+ if r['source'] not in have_calib and rng.random() < 0.08:
284
+ recs['calib'].append(r); stats[('calib', r['source'])] += 1; stats[('train', r['source'])] -= 1
285
+ else:
286
+ keep.append(r)
287
+ recs['train'] = keep
288
+ # balance: cap train per source, test per source
289
+ for sp, cap in (('train', CAP_TRAIN), ('test', 300)):
290
+ by = collections.defaultdict(list)
291
+ for r in recs[sp]:
292
+ by[r['source']].append(r)
293
+ recs[sp] = []
294
+ for src, rs in by.items():
295
+ rng.shuffle(rs)
296
+ c = cap.get(src, cap['*']) if isinstance(cap, dict) else cap
297
+ recs[sp] += rs[:c]
298
+ stats[(sp, src)] = min(len(rs), c)
299
+ for sp, rs in recs.items():
300
+ rng.shuffle(rs)
301
+ with open(os.path.join(OUT, f'pub_{sp}.jsonl'), 'w', encoding='utf-8') as f:
302
+ for r in rs:
303
+ f.write(json.dumps(r, ensure_ascii=False) + '\n')
304
+ for sp in ('train', 'calib', 'test'):
305
+ print(sp, len(recs[sp]), {s: c for (p, s), c in sorted(stats.items()) if p == sp})
training/build_skills_mix.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble synthetic skill-routing questions (with a `none` option) and merge them into the public mix.
2
+
3
+ Input: data/skills_syn/{catalogs,messages,negatives}.json (gen_skills.py) + data/pub/pub_*.jsonl.
4
+ Output: data/pub2/pub_{train,calib,test}.jsonl. Held-out catalogs go to test/calib only, so the
5
+ test measures routing over skills the model never saw.
6
+ """
7
+ import json, random, os, collections
8
+ rng = random.Random(20260926)
9
+ D = os.environ.get('DATA_DIR', 'data')
10
+ cats = json.load(open(f'{D}/skills_syn/catalogs.json', encoding='utf-8'))
11
+ msgs = json.load(open(f'{D}/skills_syn/messages.json', encoding='utf-8'))
12
+ negs = json.load(open(f'{D}/skills_syn/negatives.json', encoding='utf-8'))
13
+
14
+ NONE_DESC_RU = 'навык не нужен: обычный разговор, вопрос или задача без специального навыка'
15
+ NONE_DESC_EN = 'no skill needed: small talk, a general question or a task without a special skill'
16
+ INS_RU = 'Какой навык ассистент должен загрузить, чтобы выполнить это сообщение пользователя? Если специальный навык не нужен, выбери none.'
17
+ INS_EN = 'Which skill should the assistant load to handle this user message? Choose none if no special skill is needed.'
18
+ NOUL_RU = 'Нужен ли ассистенту специальный навык (инструмент), чтобы выполнить это сообщение?'
19
+
20
+ doms = [d for d in cats if d != 'capyagent']
21
+ rng.shuffle(doms)
22
+ held = set(doms[:6]) # unseen catalogs -> test
23
+ calib_doms = set(doms[6:9]) # unseen catalogs -> calib
24
+ def split_of(dom):
25
+ if dom in held:
26
+ return 'test'
27
+ if dom in calib_doms:
28
+ return 'calib'
29
+ if dom == 'capyagent':
30
+ r = rng.random()
31
+ return 'test' if r < 0.15 else 'calib' if r < 0.22 else 'train'
32
+ return 'train'
33
+
34
+ def is_ru(t):
35
+ return sum('а' <= ch.lower() <= 'я' for ch in t) > len(t) * 0.2
36
+
37
+ out = collections.defaultdict(list)
38
+ def rec(split, dom, text, gold, drop_gold=False):
39
+ cat = cats[dom]
40
+ k = rng.randint(6, 16)
41
+ pool = [s for s in cat if s != gold]
42
+ opts = rng.sample(pool, min(k - 2, len(pool)))
43
+ if gold != 'none' and not drop_gold:
44
+ opts.append(gold)
45
+ ru = is_ru(text)
46
+ crit = {o: cat[o] for o in opts}
47
+ crit['none'] = NONE_DESC_RU if ru else NONE_DESC_EN
48
+ keys = list(crit); rng.shuffle(keys); crit = {k_: crit[k_] for k_ in keys}
49
+ g = 'none' if (gold == 'none' or drop_gold) else gold
50
+ src = 'skills_capy' if dom == 'capyagent' else 'skills_syn'
51
+ out[split].append({'state': text, 'q': {'t': 'choice', 'ins': INS_RU if ru else INS_EN, 'crit': crit},
52
+ 'opts': list(crit), 'target': [1.0 if o == g else 0.0 for o in crit], 'gold': g, 'source': src})
53
+ if rng.random() < 0.2:
54
+ p = 0.0 if g == 'none' else 1.0
55
+ out[split].append({'state': text, 'q': {'t': 'noul', 'ins': NOUL_RU, 'crit': None}, 'opts': ['false', 'true'],
56
+ 'target': [1 - p, p], 'gold': 'true' if p else 'false', 'source': src + '_need'})
57
+
58
+ for m in msgs:
59
+ if m['domain'] not in cats or m['skill'] not in cats[m['domain']]:
60
+ continue
61
+ rec(split_of(m['domain']), m['domain'], m['text'], m['skill'], drop_gold=rng.random() < 0.08)
62
+ for n in negs:
63
+ dom = n['domain'] if n['domain'] in cats else rng.choice(list(cats))
64
+ rec(split_of(dom), dom, n['text'], 'none')
65
+
66
+ stats = {sp: collections.Counter(r['source'] for r in rs) for sp, rs in out.items()}
67
+ nones = {sp: sum(r['gold'] == 'none' for r in rs if r['q']['t'] == 'choice') for sp, rs in out.items()}
68
+ print('held-out test catalogs:', sorted(held)); print('calib catalogs:', sorted(calib_doms))
69
+ print(stats, 'none per split:', nones)
70
+ os.makedirs(f'{D}/pub2', exist_ok=True)
71
+ for sp in ('train', 'calib', 'test'):
72
+ rows = [json.loads(l) for l in open(f'{D}/pub/pub_{sp}.jsonl', encoding='utf-8')] + out[sp]
73
+ rng.shuffle(rows)
74
+ with open(f'{D}/pub2/pub_{sp}.jsonl', 'w', encoding='utf-8') as f:
75
+ for r in rows:
76
+ f.write(json.dumps(r, ensure_ascii=False) + '\n')
77
+ print(sp, len(rows))
training/gen_skills.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic skill-routing data for the public Kivok/Migom (generator: DeepSeek Flash, MIT open weights).
2
+
3
+ Stages (each cached to data/skills_syn/*.json, rerun resumes):
4
+ catalogs - fictional assistant skill catalogs across domains (+ the real CapyAgent catalog)
5
+ messages - user messages that need a given skill (ru ~75%, en ~25%)
6
+ negatives - messages that need no skill: small talk, general knowledge, and hard negatives
7
+ that mention a catalog's topics without asking for the action
8
+ Usage: python3 gen_skills.py [catalogs|messages|negatives|all]
9
+ """
10
+ import json, os, re, sys, random, threading, time, urllib.request
11
+ from concurrent.futures import ThreadPoolExecutor
12
+
13
+ OUT = os.environ.get('OUT_DIR', 'data/skills_syn')
14
+ os.makedirs(OUT, exist_ok=True)
15
+ MODEL = 'deepseek-flash'
16
+ KEY = os.environ['DEEPSEEK_API_KEY']
17
+ lock = threading.Lock()
18
+ usage = {'in': 0, 'out': 0, 'calls': 0}
19
+
20
+ DOMAINS = [
21
+ 'интернет-магазин одежды', 'стоматологическая клиника', 'автосервис', 'агентство недвижимости', 'фитнес-клуб',
22
+ 'бухгалтерия малого бизнеса', 'IT-отдел компании (helpdesk)', 'DevOps и сервера', 'личный ассистент для дома и семьи',
23
+ 'умный дом', 'школа английского онлайн', 'служба доставки еды', 'турагентство', 'юридическая консультация',
24
+ 'HR и найм', 'маркетинговое агентство', 'SMM и соцсети', 'интернет-провайдер (поддержка)', 'банк (поддержка клиентов)',
25
+ 'страховая компания', 'логистика и склад', 'производство (заводской цех)', 'ресторан', 'салон красоты',
26
+ 'ветеринарная клиника', 'онлайн-кинотеатр', 'игровая студия', 'новостная редакция', 'исследователь и учёный',
27
+ 'разработчик-фрилансер', 'продажи B2B и CRM', 'личные финансы', 'путешествия и поездки', 'здоровье и питание',
28
+ 'образование для школьников', 'музыкант и продюсер', 'видеомонтаж и YouTube', 'аналитик данных', 'кибербезопасность',
29
+ 'государственные услуги и документы',
30
+ ]
31
+
32
+
33
+ def chat(prompt, temperature=1.0, max_tokens=3000, retries=4):
34
+ body = {'model': MODEL, 'messages': [{'role': 'user', 'content': prompt}], 'temperature': temperature,
35
+ 'max_tokens': max_tokens, 'response_format': {'type': 'json_object'}}
36
+ for i in range(retries):
37
+ try:
38
+ req = urllib.request.Request('https://api.deepseek.com/chat/completions', json.dumps(body).encode(),
39
+ {'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json'})
40
+ d = json.load(urllib.request.urlopen(req, timeout=180))
41
+ with lock:
42
+ usage['in'] += d['usage']['prompt_tokens']; usage['out'] += d['usage']['completion_tokens']; usage['calls'] += 1
43
+ return json.loads(d['choices'][0]['message']['content'])
44
+ except Exception as e:
45
+ print('retry', i, repr(e)[:150], flush=True)
46
+ time.sleep(3 * (i + 1))
47
+ return None
48
+
49
+
50
+ def cached(name, fn):
51
+ p = os.path.join(OUT, name)
52
+ if os.path.exists(p):
53
+ return json.load(open(p, encoding='utf-8'))
54
+ v = fn()
55
+ json.dump(v, open(p, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
56
+ return v
57
+
58
+
59
+ def catalogs():
60
+ def one(dom):
61
+ r = chat(f"""Придумай каталог навыков (skills) для ИИ-ассистента в сфере: «{dom}».
62
+ Навык - это отдельная возможность, которую ассистент подгружает, чтобы выполнить задачу (например работа с календарём, выставление счёта, поиск по базе заказов).
63
+ Верни JSON: {{"skills": [{{"name": "kebab-case-имя-латиницей", "description": "короткое описание на русском, 5-15 слов"}}]}}
64
+ Ровно 16 навыков, разные по смыслу, без пересечений. Некоторые описания можно делать на английском (примерно 1 из 4).""", temperature=0.9)
65
+ if r and isinstance(r.get('skills'), list):
66
+ return dom, {s['name']: s['description'] for s in r['skills'] if s.get('name') and s.get('description')}
67
+ return dom, None
68
+ res = {}
69
+ with ThreadPoolExecutor(8) as ex:
70
+ for dom, cat in ex.map(one, DOMAINS):
71
+ if cat:
72
+ res[dom] = cat
73
+ res['capyagent'] = json.load(open(os.environ['EXTRA_CATALOG'], encoding='utf-8')) if os.environ.get('EXTRA_CATALOG') else {}
74
+ return res
75
+
76
+
77
+ def messages(cats):
78
+ jobs = []
79
+ for dom, cat in cats.items():
80
+ items = list(cat.items())
81
+ for i in range(0, len(items), 4):
82
+ jobs.append((dom, items[i:i + 4]))
83
+
84
+ def one(job):
85
+ dom, items = job
86
+ spec = '\n'.join(f'- {n}: {d}' for n, d in items)
87
+ r = chat(f"""Сфера ассистента: «{dom}». Вот навыки:
88
+ {spec}
89
+ Для КАЖДОГО навыка напиши 9 разных сообщений пользователя, для выполнения которых ассистенту нужен именно этот навык:
90
+ 6 на русском и 3 на английском. Делай их живыми и разнообразными: короткие и длинные, вежливые и грубые, с опечатками и без,
91
+ иногда с лишними подробностями, иногда косвенно (не называй навык прямо), иногда с упоминанием другой темы, но главная просьба - про этот навык.
92
+ Верни JSON: {{"items": [{{"skill": "имя навыка", "messages": ["...", "..."]}}]}}""", temperature=1.0, max_tokens=4000)
93
+ out = []
94
+ if r:
95
+ for it in r.get('items', []):
96
+ if it.get('skill') in dict(items):
97
+ out += [{'domain': dom, 'skill': it['skill'], 'text': m} for m in it.get('messages', []) if isinstance(m, str) and len(m) > 3]
98
+ return out
99
+ res = []
100
+ with ThreadPoolExecutor(8) as ex:
101
+ for k, o in enumerate(ex.map(one, jobs)):
102
+ res += o
103
+ if k % 20 == 0:
104
+ print('messages', k, len(jobs), len(res), usage, flush=True)
105
+ return res
106
+
107
+
108
+ def negatives(cats):
109
+ jobs = [('general', None)] * 30 + [(dom, cat) for dom, cat in cats.items()]
110
+
111
+ def one(job):
112
+ dom, cat = job
113
+ if cat is None:
114
+ p = """Напиши 25 разных сообщений пользователя ИИ-ассистенту, для которых НЕ нужен никакой специальный навык или инструмент:
115
+ болтовня, приветствия, благодарности, общие вопросы из головы (факты, объяснения, советы), просьбы переформулировать или придумать текст,
116
+ мнения, шутки, короткие реакции («ок», «понял, спасибо»). 18 на русском, 7 на английском. Разная длина и стиль.
117
+ Верни JSON: {"messages": ["...", "..."]}"""
118
+ else:
119
+ spec = '\n'.join(f'- {n}: {d}' for n, d in list(cat.items())[:16])
120
+ p = f"""Ассистент в сфере «{dom}» умеет вот это (навыки):
121
+ {spec}
122
+ Напиши 16 сообщений пользователя, которые КАСАЮТСЯ тем этих навыков, но НЕ требуют их запускать:
123
+ рассказ о себе, эмоция, благодарность за прошлую работу, общий вопрос «как это вообще устроено», мнение, шутка, уточнение, не требующее действия.
124
+ 12 на русском, 4 на английском. Верни JSON: {{"messages": ["...", "..."]}}"""
125
+ r = chat(p, temperature=1.0)
126
+ return [{'domain': dom if cat else None, 'text': m} for m in (r or {}).get('messages', []) if isinstance(m, str) and len(m) > 1]
127
+ res = []
128
+ with ThreadPoolExecutor(8) as ex:
129
+ for o in ex.map(one, jobs):
130
+ res += o
131
+ return res
132
+
133
+
134
+ if __name__ == '__main__':
135
+ stage = sys.argv[1] if len(sys.argv) > 1 else 'all'
136
+ cats = cached('catalogs.json', catalogs)
137
+ print('catalogs', len(cats), sum(len(c) for c in cats.values()), usage, flush=True)
138
+ if stage in ('messages', 'all'):
139
+ msgs = cached('messages.json', lambda: messages(cats))
140
+ print('messages', len(msgs), usage, flush=True)
141
+ if stage in ('negatives', 'all'):
142
+ neg = cached('negatives.json', lambda: negatives(cats))
143
+ print('negatives', len(neg), usage, flush=True)
144
+ print('USAGE', usage)
training/to_jevk5_pub.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert the public mix to JevK5 LoRA format (subset: Migom trains ~0.5 items/s on Arc)."""
2
+ import json, random, collections, sys, os
3
+ src_dir, out_dir, per_src_train, per_src_dev = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
4
+ rng = random.Random(7)
5
+ os.makedirs(out_dir, exist_ok=True)
6
+ def conv(r, i):
7
+ q = r['q']; t = q['t']
8
+ keys = ['false', 'true'] if t == 'noul' else r['opts']
9
+ it = {'id': f"{r['source']}-{i}", 'source': r['source'], 'state': r['state'],
10
+ 'question': {'type': t, 'instructions': q['ins'], 'criteria': q['crit']}, 'expected': r['gold']}
11
+ if max(r['target']) < 0.999:
12
+ it['gold_probs'] = {k: v for k, v in zip(keys, r['target'])}
13
+ return it
14
+ for name, out, cap in (('pub_train', 'train', per_src_train), ('pub_calib', 'dev', per_src_dev)):
15
+ by = collections.defaultdict(list)
16
+ for l in open(os.path.join(src_dir, name + '.jsonl'), encoding='utf-8'):
17
+ r = json.loads(l); by[r['source']].append(r)
18
+ rows = []
19
+ for s, rs in by.items():
20
+ rng.shuffle(rs); rows += rs[:cap]
21
+ rng.shuffle(rows)
22
+ with open(os.path.join(out_dir, out + '.jsonl'), 'w', encoding='utf-8') as f:
23
+ for i, r in enumerate(rows): f.write(json.dumps(conv(r, i), ensure_ascii=False) + '\n')
24
+ print(out, len(rows))
training/train_decider_lora.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA fine-tune of Mapika/decider-2b on our public typed-decision mix (JevK5-format jsonl: state/question/expected).
2
+
3
+ Uses decider's own prompt builder (plain state-first layout, "Answer: (" slot, option-letter logits), so the
4
+ trained model is served unchanged by decider.infer.Decider. Score questions are trained as lists (isolated=False).
5
+ Usage: python train_decider_lora.py BASE_DIR DATA_DIR OUT_DIR [--epochs 1 --lr 1e-4 --rank 32]
6
+ """
7
+ import argparse, json, os, random, shutil, sys, time, math
8
+ import torch
9
+ from peft import LoraConfig, get_peft_model
10
+ from decider.model import DecisionModel, collate
11
+ from decider.prompt import build
12
+ from decider.data.core import Example, Q
13
+ from decider.train import batches_by_tokens, loss_fn
14
+
15
+ ap = argparse.ArgumentParser()
16
+ ap.add_argument('base'); ap.add_argument('data'); ap.add_argument('out')
17
+ ap.add_argument('--epochs', type=float, default=1.0); ap.add_argument('--lr', type=float, default=1e-4)
18
+ ap.add_argument('--rank', type=int, default=32); ap.add_argument('--max-tokens', type=int, default=8192)
19
+ ap.add_argument('--max-ctx', type=int, default=1536); ap.add_argument('--limit', type=int, default=0)
20
+ ap.add_argument('--dtype', default='bf16', choices=['bf16', 'fp16']); ap.add_argument('--device', default='cuda')
21
+ ap.add_argument('--max-minutes', type=float, default=0, help='stop training after this many minutes and save')
22
+ ap.add_argument('--adapter-out', default='', help='also save the LoRA adapter here')
23
+ ap.add_argument('--version', default='migom-2b-decider-pub1')
24
+ a = ap.parse_args()
25
+ sys.stdout.reconfigure(encoding='utf-8')
26
+ rng = random.Random(0)
27
+ dev = a.device
28
+ DT = torch.bfloat16 if a.dtype == 'bf16' else torch.float16
29
+
30
+
31
+ def to_example(r):
32
+ q = r['question']; t = q['type']; crit = q.get('criteria'); gold = str(r['expected'])
33
+ if t == 'choice':
34
+ crit = dict.fromkeys(crit) if isinstance(crit, list) else crit
35
+ names = list(crit); opts = [n if crit[n] in (None, '') else f'{n}: {crit[n]}' for n in names]
36
+ gi = names.index(gold) if gold in names else -1
37
+ elif t == 'score':
38
+ opts = [f'{i}: {c}' for i, c in enumerate(crit)]; gi = int(gold)
39
+ else:
40
+ c = crit or {}
41
+ f, tr = c.get('false'), c.get('true')
42
+ opts = ['no' if not f else f'no: {f}', 'yes' if not tr else f'yes: {tr}']
43
+ gi = 1 if gold == 'true' else 0
44
+ if gi < 0:
45
+ return None
46
+ return Example(context=r['state'], qs=[Q(text=q['instructions'], options=opts, gold=gi)], task=r.get('source', ''))
47
+
48
+
49
+ def load(name):
50
+ ex = [to_example(json.loads(l)) for l in open(os.path.join(a.data, name), encoding='utf-8')]
51
+ ex = [e for e in ex if e]
52
+ return ex[:a.limit] if a.limit else ex
53
+
54
+
55
+ t0 = time.time()
56
+ m = DecisionModel(a.base, dtype=DT, grad_ckpt=True)
57
+ tok = m.tok
58
+ names = {n.rsplit('.', 1)[-1] for n, _ in m.lm.named_modules()}
59
+ targets = [t for t in ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'in_proj_qkvz', 'in_proj_ba', 'in_proj_qkv', 'in_proj_z',
60
+ 'in_proj_b', 'in_proj_a', 'out_proj', 'gate_proj', 'up_proj', 'down_proj') if t in names]
61
+ peft_model = get_peft_model(m.lm, LoraConfig(r=a.rank, lora_alpha=2 * a.rank, lora_dropout=0.05, target_modules=targets))
62
+ peft_model.print_trainable_parameters()
63
+ m.lm = peft_model.base_model.model # same CausalLM with LoRA layers injected in place; DecisionModel keeps calling .model / .lm_head
64
+ m.to(dev)
65
+
66
+ train = load('train.jsonl'); dev_ex = load('dev.jsonl')
67
+ items = [build(e, tok, rng, max_options=255, max_ctx_tokens=a.max_ctx) for e in train]
68
+ dev_items = [build(e, tok, random.Random(1), max_options=255, max_ctx_tokens=a.max_ctx) for e in dev_ex]
69
+ print('train', len(items), 'dev', len(dev_items), flush=True)
70
+
71
+
72
+ @torch.no_grad()
73
+ def evaluate():
74
+ m.eval(); ok = n = 0
75
+ for idx in batches_by_tokens(dev_items, a.max_tokens, random.Random(2)):
76
+ b = collate([dev_items[i] for i in idx], tok.pad_token_id)
77
+ bt = {k: v.to(dev) if torch.is_tensor(v) else v for k, v in b.items()}
78
+ lg = m(bt); g = bt['golds']; msk = g >= 0
79
+ ok += int((lg[msk].argmax(-1) == g[msk]).sum()); n += int(msk.sum())
80
+ m.train(); return ok / max(1, n)
81
+
82
+
83
+ print('before dev acc', round(evaluate(), 4), flush=True)
84
+ params = [p for p in m.parameters() if p.requires_grad]
85
+ opt = torch.optim.AdamW(params, lr=a.lr, weight_decay=0.0)
86
+ plan = []
87
+ for ep in range(math.ceil(a.epochs)):
88
+ plan += batches_by_tokens(items, a.max_tokens, random.Random(10 + ep))
89
+ total = int(len(plan) * a.epochs / math.ceil(a.epochs))
90
+ scaler = torch.amp.GradScaler('cuda', enabled=a.dtype == 'fp16')
91
+ sch = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / 20) * 0.5 * (1 + math.cos(math.pi * s / total)))
92
+ m.train(); win = []
93
+ for step, idx in enumerate(plan[:total]):
94
+ b = collate([items[i] for i in idx], tok.pad_token_id)
95
+ bt = {k: v.to(dev) if torch.is_tensor(v) else v for k, v in b.items()}
96
+ loss, ce = loss_fn(m(bt).float(), bt['golds'], bt['nopts'])
97
+ opt.zero_grad(set_to_none=True); scaler.scale(loss).backward(); scaler.unscale_(opt)
98
+ torch.nn.utils.clip_grad_norm_(params, 1.0); scaler.step(opt); scaler.update(); sch.step()
99
+ win.append(loss.item())
100
+ if a.max_minutes and time.time() - t0 > 60 * a.max_minutes:
101
+ print(f'time limit: stopping at step {step + 1}/{total}', flush=True); break
102
+ if (step + 1) % 25 == 0:
103
+ eta = (total - step - 1) * (time.time() - t0) / (step + 1) / 60
104
+ print(f'step {step + 1}/{total} loss {sum(win) / len(win):.4f} ~{eta:.0f} min left', flush=True); win = []
105
+ print('after dev acc', round(evaluate(), 4), flush=True)
106
+ if a.adapter_out:
107
+ peft_model.save_pretrained(a.adapter_out)
108
+ merged = peft_model.merge_and_unload()
109
+ os.makedirs(a.out, exist_ok=True)
110
+ merged.save_pretrained(a.out); tok.save_pretrained(a.out)
111
+ cfg = json.load(open(os.path.join(a.base, 'decider_config.json')))
112
+ cfg.update(version=a.version, parent=cfg.get('version'), stage=f'LoRA r{a.rank} on the open typed-decision mix, {a.epochs} epoch(s)')
113
+ json.dump(cfg, open(os.path.join(a.out, 'decider_config.json'), 'w'), indent=1)
114
+ print('done in', int(time.time() - t0), 's', flush=True)