Jarbas commited on
Commit
ca1b4bc
·
verified ·
1 Parent(s): dfe4d31

Jevhakim decision-model demo

Browse files
Files changed (3) hide show
  1. README.md +77 -7
  2. app.py +454 -0
  3. requirements.txt +3 -0
README.md CHANGED
@@ -1,13 +1,83 @@
1
  ---
2
  title: Jevhakim
3
- emoji: 🔥
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Jevhakim
3
+ emoji: ⚖️
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 6.28.0
8
+ python_version: "3.12"
9
  app_file: app.py
10
+ short_description: Typed decisions for Arabic text, MSA and dialects
11
+ startup_duration_timeout: 1h
12
+ license: cc-by-nc-sa-4.0
13
+ models:
14
+ - TigreGotico/jevhakim-4b
15
+ - TigreGotico/jevhakim-0.8b
16
+ - Qwen/Qwen3.5-0.8B-Base
17
+ - Qwen/Qwen3.5-4B-Base
18
+ datasets:
19
+ - TigreGotico/jevhakim-decisions
20
+ tags:
21
+ - decision-model
22
+ - arabic
23
+ - arabic-dialects
24
  ---
25
 
26
+ # Jevhakim
27
+
28
+ Jevhakim is a decision model for Arabic, both Modern Standard Arabic and the dialects. It reads a state, which is a
29
+ text or a JSON object, and a set of typed questions about it. It returns a probability for each option of each
30
+ question in one forward pass. It does not generate text.
31
+
32
+ This Space runs two models on ZeroGPU, in bf16. Pick one with the model selector.
33
+ [Jevhakim-4B](https://huggingface.co/TigreGotico/jevhakim-4b) is more accurate, and
34
+ [Jevhakim-0.8B](https://huggingface.co/TigreGotico/jevhakim-0.8b) is smaller. Both are
35
+ [Kev](https://github.com/jaredpalmer/kev) fine-tuned on the
36
+ [TigreGotico/jevhakim-decisions](https://huggingface.co/datasets/TigreGotico/jevhakim-decisions) dataset.
37
+
38
+ ## Request shape
39
+
40
+ A request follows Kev's System One format (`POST /v1/systemone`). Keys are English code identifiers: the state's
41
+ field names, the question ids and the option names. Values are Arabic.
42
+
43
+ ```json
44
+ {
45
+ "state": {"message": "اتخصم مني فلوس الباقة مرتين الشهر ده، وعايز الفلوس ترجعلي."},
46
+ "questions": {
47
+ "category": {"type": "choice", "instructions": "إلى أي قسم ينتمي هذا الطلب؟",
48
+ "criteria": {"billing_payment": "الفواتير والدفع", "home_internet": "الإنترنت المنزلي", "devices": "الأجهزة"}},
49
+ "needs_human": {"type": "noul", "instructions": "هل يحتاج هذا الطلب إلى موظف بشري؟"},
50
+ "urgency": {"type": "score", "instructions": "كم هو ملحّ طلب العميل؟",
51
+ "criteria": ["غير مستعجل", "مستعجل قليلًا", "مستعجل", "عاجل جدًا"]}
52
+ }
53
+ }
54
+ ```
55
+
56
+ There are three question types. A `choice` question picks one of its named options, and each option can have a
57
+ description. A `noul` question is yes or no, and the answer is p(yes). A `score` question picks a level on an ordered
58
+ list, and the answer is the expected level.
59
+
60
+ The **Questions** tab builds a request from a form. The **Raw JSON** tab takes a raw JSON request and shows the raw
61
+ JSON answer. The same two functions are on the Gradio API as `/decide` and `/systemone`.
62
+
63
+ ## Measured results
64
+
65
+ On the test split of 141,217 questions, Jevhakim-4B has accuracy 0.765, ECE 0.061 and Brier score 0.328.
66
+ Jevhakim-0.8B has accuracy 0.661, ECE 0.063 and Brier score 0.438. Stock Kev-0.8B has accuracy 0.503. On the
67
+ evaluation-only sources, which give no training rows, the accuracy is 0.727 for Jevhakim-4B, 0.585 for Jevhakim-0.8B
68
+ and 0.490 for stock Kev-0.8B.
69
+
70
+ ## How it runs
71
+
72
+ The Space installs Kev from GitHub at commit `1f04cfa28e6a708485b28a9585010b9e436b776e` and loads the model with
73
+ Kev's own loader, as `python -m kev.serve --run TigreGotico/jevhakim-0.8b` does. The probabilities use the calibration
74
+ temperature stored in the model's `head.pt`.
75
+
76
+ `JEVHAKIM_MODELS` names the models to serve, for example `JEVHAKIM_MODELS=Jevhakim-0.8B`. Off ZeroGPU,
77
+ `JEVHAKIM_DEVICE_4B` and `JEVHAKIM_DEVICE_08B` place each model, for example `JEVHAKIM_DEVICE_4B=cpu`.
78
+
79
+ ## Licence and limits
80
+
81
+ The models and the dataset are under CC BY-NC-SA 4.0, for non-commercial research only. Several training sources are
82
+ research-only. The instruction wording is model-written Modern Standard Arabic that no native speaker has reviewed. Do
83
+ not use the model for decisions with legal, medical, financial or employment consequences without human review.
app.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jevhakim on Hugging Face Spaces (Gradio, ZeroGPU).
2
+
3
+ A state and a set of typed questions in, a probability per option out, in one forward pass. The model is loaded through
4
+ Kev's own loader (kev.checkpoint), the same path `python -m kev.serve --run TigreGotico/jevhakim-0.8b` takes, and
5
+ requests go through kev.api and kev.model.admit exactly as /v1/systemone does. Both sizes load in bf16 at start-up, and
6
+ each forward pass runs inside @spaces.GPU, so a request holds a GPU only for that pass.
7
+ """
8
+ import html
9
+ import json
10
+ import os
11
+ import time
12
+ from dataclasses import replace
13
+
14
+ import gradio as gr
15
+ import spaces
16
+ import torch
17
+ from pydantic import ValidationError
18
+
19
+ from kev.api import SystemOneRequest, output_tokens, render, to_answers, to_record
20
+ from kev.checkpoint import Checkpoint, LoadOptions
21
+ from kev.model import admit
22
+
23
+ MODELS = {"Jevhakim-4B": "TigreGotico/jevhakim-4b", "Jevhakim-0.8B": "TigreGotico/jevhakim-0.8b"}
24
+ # JEVHAKIM_MODELS="Jevhakim-0.8B" serves only the named models.
25
+ if os.environ.get("JEVHAKIM_MODELS"):
26
+ MODELS = {name: MODELS[name] for name in os.environ["JEVHAKIM_MODELS"].split(",")}
27
+ DEFAULT = "Jevhakim-4B" if "Jevhakim-4B" in MODELS else next(iter(MODELS))
28
+ LINK_08B = "https://huggingface.co/TigreGotico/jevhakim-0.8b"
29
+ LINK_4B = "https://huggingface.co/TigreGotico/jevhakim-4b"
30
+ LINK_DATA = "https://huggingface.co/datasets/TigreGotico/jevhakim-decisions"
31
+
32
+ LOADED = {}
33
+ for name, repo in MODELS.items():
34
+ t0 = time.perf_counter()
35
+ checkpoint = Checkpoint(repo)
36
+ opts = LoadOptions.from_env()
37
+ if opts.dtype is None:
38
+ opts = replace(opts, dtype=torch.bfloat16)
39
+ # ZeroGPU has no GPU at start-up: load on the CPU, then move. ZeroGPU defers the move until a @spaces.GPU call, and
40
+ # Kev builds its input tensors on model.device. Off ZeroGPU, JEVHAKIM_DEVICE_<SIZE> places a model, e.g.
41
+ # JEVHAKIM_DEVICE_4B=cpu keeps the 4B in RAM on a laptop GPU too small for it (JEVHAKIM_DEVICE_08B for the 0.8B).
42
+ key = name.split("-", 1)[1].replace(".", "").upper()
43
+ device = "cuda" if os.environ.get("SPACES_ZERO_GPU") else os.environ.get(
44
+ f"JEVHAKIM_DEVICE_{key}", "cuda" if torch.cuda.is_available() else "cpu")
45
+ tok, model = checkpoint.load("cpu", opts)
46
+ model.to(device)
47
+ model.device = torch.device(device)
48
+ LOADED[name] = tok, model
49
+ print(f"[jevhakim] {repo}: base={checkpoint.meta.base}@{checkpoint.meta.base_revision} dtype={model.dtype} "
50
+ f"temperature={model.head.temperature:.2f} device={device} loaded in {time.perf_counter() - t0:.0f} s",
51
+ flush=True)
52
+
53
+
54
+ @spaces.GPU(duration=30)
55
+ def forward(name, enc):
56
+ return [p.float().cpu() for p in LOADED[name][1].probs(enc)]
57
+
58
+
59
+ class RequestError(ValueError):
60
+ """A request the model cannot answer: bad JSON, a bad question, or a state that is too long."""
61
+
62
+
63
+ def parse_request(payload):
64
+ try:
65
+ return SystemOneRequest.model_validate(payload)
66
+ except ValidationError as e:
67
+ first = e.errors()[0]
68
+ where = ".".join(str(x) for x in first.get("loc", ()))
69
+ raise RequestError(f"Invalid request at `{where}`: {first.get('msg')}") from None
70
+
71
+
72
+ def systemone(req, name=DEFAULT):
73
+ """The /v1/systemone response body kev.serve returns for this request, plus the latency of the forward pass."""
74
+ if name not in LOADED:
75
+ raise RequestError(f"Unknown model: {name}. Choices: {', '.join(MODELS)}.")
76
+ tokenizer, model = LOADED[name]
77
+ rec, meta = to_record(req)
78
+ try:
79
+ enc = admit(model, tokenizer, rec)
80
+ except ValueError as e:
81
+ raise RequestError(str(e)) from None
82
+ start = time.perf_counter()
83
+ probs = forward(name, enc)
84
+ ms = (time.perf_counter() - start) * 1000
85
+ answers = to_answers([p.tolist() for p in probs], meta)
86
+ return {"model": MODELS[name], "answers": answers,
87
+ "usage": {"input_tokens": len(enc["ids"]), "output_tokens": output_tokens(tokenizer, answers)},
88
+ "latency_ms": round(ms, 1)}
89
+
90
+
91
+ # ------------------------------------------------------------------ rendering
92
+
93
+ CARD = ("border:1px solid var(--border-color-primary);border-radius:10px;padding:12px 14px;margin-bottom:10px;"
94
+ "background:var(--background-fill-secondary);")
95
+ TRACK = "position:relative;display:block;height:8px;border-radius:999px;background:var(--border-color-primary);overflow:hidden;"
96
+ MONO = "font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-subdued)"
97
+ TYPE_LABEL = {"choice": "choice · اختيار", "noul": "yes/no · نعم/لا", "score": "score · مقياس"}
98
+
99
+
100
+ def bar(label, p, top, key=None):
101
+ """One option row. The card is right-to-left, so the label sits on the right and the bar fills from the right."""
102
+ fill = "var(--color-accent)" if top else "var(--body-text-color-subdued)"
103
+ weight = "600" if top else "400"
104
+ width = max(0.0, min(1.0, p)) * 100
105
+ tag = f' <bdi style="{MONO}">{html.escape(key)}</bdi>' if key and key != label else ""
106
+ return ('<div style="display:grid;grid-template-columns:minmax(0,14rem) minmax(0,1fr) 3.2rem;align-items:center;'
107
+ 'gap:0 12px;font-size:13px;line-height:24px">'
108
+ f'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:{weight}" '
109
+ f'title="{html.escape(label)}"><bdi>{html.escape(label)}</bdi>{tag}</span>'
110
+ f'<span style="{TRACK}"><span style="position:absolute;top:0;bottom:0;inset-inline-start:0;border-radius:999px;'
111
+ f'background:{fill};width:{width:.1f}%"></span></span>'
112
+ f'<span dir="ltr" style="text-align:end;font-variant-numeric:tabular-nums;font-weight:{weight}">{p:.2f}</span></div>')
113
+
114
+
115
+ def render_answers(req, resp):
116
+ usage = resp["usage"]
117
+ parts = ['<div dir="ltr" style="font-size:12px;color:var(--body-text-color-subdued);margin:6px 0 10px">'
118
+ f'{len(resp["answers"])} question(s) · {usage["input_tokens"]} input tokens · '
119
+ f'{resp["model"].split("/")[1]} · {resp["latency_ms"]:.0f} ms · no text generated</div>']
120
+ for qid, a in resp["answers"].items():
121
+ question = req.questions[qid]
122
+ instructions = render(question.instructions)
123
+ if a["type"] == "noul":
124
+ p = a["noul"]
125
+ headline, detail = ("نعم · yes" if p >= 0.5 else "لا · no"), f"p(yes) {p:.2f}"
126
+ rows = bar("نعم", p, p >= 0.5, "yes") + bar("لا", 1 - p, p < 0.5, "no")
127
+ elif a["type"] == "choice":
128
+ names = {k: render(v) if v else k for k, v in question.criteria.items()}
129
+ headline, detail = names[a["choice"]], f"confidence {a['confidence']:.2f}"
130
+ ranked = sorted(a["probabilities"].items(), key=lambda kv: -kv[1])
131
+ rows = "".join(bar(names[k], v, k == a["choice"], k) for k, v in ranked)
132
+ else:
133
+ legend, dist = a["legend"], a["probabilities"]
134
+ top = max(dist, key=dist.get)
135
+ headline = f"{a['score']:.2f} / {len(legend) - 1}"
136
+ detail = f"confidence {a['confidence']:.2f}"
137
+ rows = "".join(bar(legend[str(i)], dist[str(i)], str(i) == top, str(i)) for i in range(len(legend)))
138
+ parts.append(f'<div dir="rtl" style="{CARD}"><div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">'
139
+ f'<bdi style="font-family:var(--font-mono);font-size:13px;font-weight:600">{html.escape(qid)}</bdi>'
140
+ f'<bdi style="font-size:11px;color:var(--body-text-color-subdued)">{TYPE_LABEL[a["type"]]}</bdi></div>'
141
+ f'<div style="font-size:14px;margin:2px 0 6px">{html.escape(instructions)}</div>'
142
+ '<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:8px">'
143
+ f'<bdi style="font-size:17px;font-weight:600">{html.escape(str(headline))}</bdi>'
144
+ f'<bdi style="font-size:12px;color:var(--body-text-color-subdued)">{detail}</bdi></div>{rows}</div>')
145
+ return "".join(parts)
146
+
147
+
148
+ def error_html(message):
149
+ return (f'<div style="{CARD}border-color:var(--error-border-color, #d33);">'
150
+ f'<b>Error:</b> {html.escape(message)}</div>')
151
+
152
+
153
+ # ------------------------------------------------------------------ question builder
154
+
155
+ def parse_state(text):
156
+ """Plain text, or a JSON object or array."""
157
+ t = (text or "").strip()
158
+ if t[:1] in "{[":
159
+ try:
160
+ return json.loads(t)
161
+ except json.JSONDecodeError:
162
+ pass
163
+ return t
164
+
165
+
166
+ def parse_options(text):
167
+ """One option per line, `name: description` or just `name`."""
168
+ criteria = {}
169
+ for line in (text or "").splitlines():
170
+ name, _, desc = line.partition(":")
171
+ name, desc = name.strip(), desc.strip()
172
+ if name:
173
+ criteria[name] = desc or None
174
+ return criteria
175
+
176
+
177
+ def build_request(state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels):
178
+ """The System One request the builder describes. A question with an empty id is left out."""
179
+ ids = [i.strip() for i in (choice_id, noul_id, score_id) if i and i.strip()]
180
+ if len(ids) != len(set(ids)):
181
+ raise RequestError("Each question needs a different id.")
182
+ questions = {}
183
+ if choice_id.strip():
184
+ questions[choice_id.strip()] = {"type": "choice", "instructions": choice_instr, "criteria": parse_options(choice_opts)}
185
+ if noul_id.strip():
186
+ questions[noul_id.strip()] = {"type": "noul", "instructions": noul_instr}
187
+ if score_id.strip():
188
+ levels = [line.strip() for line in (score_levels or "").splitlines() if line.strip()]
189
+ questions[score_id.strip()] = {"type": "score", "instructions": score_instr, "criteria": levels}
190
+ if not (state or "").strip():
191
+ raise RequestError("Write a state: the text or the JSON object the model reads.")
192
+ if not questions:
193
+ raise RequestError("Define at least one question (a non-empty id).")
194
+ return {"state": parse_state(state), "model": "kev-latest", "questions": questions}
195
+
196
+
197
+ def decide(name, state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels):
198
+ """Answer the builder's questions about the state. Returns the rendered answers, the request and the response."""
199
+ try:
200
+ payload = build_request(state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels)
201
+ req = parse_request(payload)
202
+ resp = systemone(req, name)
203
+ except RequestError as e:
204
+ return error_html(str(e)), {}, {"error": str(e)}
205
+ return render_answers(req, resp), payload, resp
206
+
207
+
208
+ def decide_raw(name, request_json):
209
+ """Answer a raw System One request (JSON text). Returns a status line and the response or an error object."""
210
+ try:
211
+ payload = json.loads(request_json or "")
212
+ except json.JSONDecodeError as e:
213
+ message = f"Invalid JSON: {e}"
214
+ return f"**Error:** {message}", {"error": message}
215
+ if not isinstance(payload, dict):
216
+ message = 'The request must be a JSON object with "state" and "questions".'
217
+ return f"**Error:** {message}", {"error": message}
218
+ try:
219
+ resp = systemone(parse_request(payload), name)
220
+ except RequestError as e:
221
+ return f"**Error:** {e}", {"error": str(e)}
222
+ return f"Answered in {resp['latency_ms']:.0f} ms.", resp
223
+
224
+
225
+ # ------------------------------------------------------------------ examples
226
+
227
+ DIALECT_GROUPS = ("egy: اللهجة المصرية\nlev: اللهجة الشامية\nglf: اللهجة الخليجية\nmag: اللهجة المغاربية\n"
228
+ "irq: اللهجة العراقية\nmsa: العربية الفصحى")
229
+ DIALECTNESS = "فصحى خالصة\nفصحى مع قليل من العامية\nمزيج متقارب من الفصحى والعامية\nعامية في الغالب"
230
+ STARS = "نجمة واحدة: سيئ جدًّا\nنجمتان: سيئ\nثلاث نجوم: مقبول\nأربع نجوم: جيد\nخمس نجوم: ممتاز"
231
+ SAFETY = ("safe: رسالة آمنة\nsafe_sensitive: رسالة آمنة تتناول موضوعًا حساسًا\nhate_harassment: كراهية أو تحرّش\n"
232
+ "privacy_pii: انتهاك للخصوصية أو بيانات شخصية\nnonviolent_crime: جريمة غير عنيفة\n"
233
+ "violent_weapons: عنف أو أسلحة\nmisinformation: معلومات مضلِّلة\n"
234
+ "specialized_advice: طلب نصيحة متخصصة (طبية أو قانونية أو مالية) تتطلب خبيرًا\n"
235
+ "prompt_injection: حقن تعليمات في المدخلات\njailbreak: محاولة لتجاوز قيود المساعد\n"
236
+ "sexual_content: محتوى جنسي\nself_harm: إيذاء النفس")
237
+ TOPICS = ("politic: السياسة\nfinance: المال والاقتصاد\nsport: الرياضة\ntechnology: التكنولوجيا\n"
238
+ "medical: الطب والصحة\nculture: الثقافة\nreligion: الدين")
239
+ NO_CHOICE, NO_NOUL, NO_SCORE = ("", "", ""), ("", ""), ("", "", "")
240
+
241
+ EXAMPLES = [
242
+ {
243
+ "name": "Dialect group (Egyptian)",
244
+ "state": "يا عم انا مش فاهم انت عايز ايه بالظبط، قولي بسرعة عشان ورايا مشوار ومش هستناك كتير.",
245
+ "choice": ("dialect", "ما اللهجة الغالبة على هذا النص؟", DIALECT_GROUPS),
246
+ "noul": NO_NOUL,
247
+ "score": ("dialectness", "ما مقدار استخدام اللهجة العامية في هذا النص؟", DIALECTNESS),
248
+ },
249
+ {
250
+ "name": "Dialect by country (Moroccan)",
251
+ "state": "واش نتا غادي تجي معانا لدار خالتي غدا ولا لا؟ قولها ليا دابا باش نعرف شحال من واحد غادي نكونو.",
252
+ "choice": ("dialect", "إلى لهجة أي بلد ينتمي هذا النص؟",
253
+ "egypt: مصر\nsaudi_arabia: السعودية\nkuwait: الكويت\nlebanon: لبنان\nsyria: سوريا\niraq: العراق\n"
254
+ "sudan: السودان\nlibya: ليبيا\ntunisia: تونس\nalgeria: الجزائر\nmorocco: المغرب"),
255
+ "noul": NO_NOUL,
256
+ "score": NO_SCORE,
257
+ },
258
+ {
259
+ "name": "Support routing (Gulf)",
260
+ "state": '{"message": "السلام عليكم، من أمس والنت فاصل عندي في البيت، كلمت خدمة العملاء مرتين وما أحد رد علي. '
261
+ 'أبي أحد يجي يشوف الراوتر اليوم لو سمحتوا لأن عندي دوام من البيت."}',
262
+ "choice": ("category", "إلى أي قسم ينتمي هذا الطلب؟",
263
+ "home_internet: الإنترنت المنزلي\nbilling_payment: الفواتير والدفع\nmobile_line: خط الجوال\n"
264
+ "devices: الأجهزة\nplans_subscription: الباقات والاشتراكات\ncustomer_care: خدمة العملاء العامة"),
265
+ "noul": ("needs_human", "هل يحتاج هذا الطلب إلى موظف بشري؟"),
266
+ "score": ("urgency", "كم هو ملحّ طلب العميل؟", "غير مستعجل\nمستعجل قليلًا\nمستعجل\nعاجل جدًا"),
267
+ },
268
+ {
269
+ "name": "Support routing (Egyptian)",
270
+ "state": '{"message": "لو سمحت انا اتخصم مني فلوس الباقة مرتين الشهر ده، وعايز الفلوس ترجعلي في أسرع وقت."}',
271
+ "choice": ("category", "إلى أي قسم ينتمي هذا الطلب؟",
272
+ "home_internet: الإنترنت المنزلي\nbilling_payment: الفواتير والدفع\nmobile_line: خط الجوال\n"
273
+ "devices: الأجهزة\nplans_subscription: الباقات والاشتراكات\ncustomer_care: خدمة العملاء العامة"),
274
+ "noul": ("needs_human", "هل يحتاج هذا الطلب إلى موظف بشري؟"),
275
+ "score": NO_SCORE,
276
+ },
277
+ {
278
+ "name": "Review rating 1–5 (Egyptian)",
279
+ "state": '{"review": "الموبايل وصل بعد ميعاده بأسبوع والكرتونة كانت مفتوحة. بس بصراحة الشاشة حلوة '
280
+ 'والبطارية بتقعد يوم كامل، وبالسعر ده مش وحش."}',
281
+ "choice": ("sentiment", "هل هذا النص إيجابي أم سلبي أم محايد؟",
282
+ "positive: إيجابي\nnegative: سلبي\nmixed: مختلط: فيه إيجابي وسلبي"),
283
+ "noul": NO_NOUL,
284
+ "score": ("rating", "كم نجمة أعطى العميل لهذا المنتج؟", STARS),
285
+ },
286
+ {
287
+ "name": "Offensive tweet (Levantine)",
288
+ "state": "شو هالحكم الحمار؟ والله لازم ينطرد من الملعب هو والاتحاد كلو، شلة فاشلين.",
289
+ "choice": NO_CHOICE,
290
+ "noul": ("offensive", "هل تحتوي التغريدة على شتم أو إهانة؟"),
291
+ "score": NO_SCORE,
292
+ },
293
+ {
294
+ "name": "Guardrail category (Gulf)",
295
+ "state": '{"message": "كيف أقدر أدخل على حساب زوجتي في الواتساب بدون ما تدري؟ أبي أشوف رسايلها كلها."}',
296
+ "choice": ("safety_class", "ما فئة هذه الرسالة في نظام الحماية؟", SAFETY),
297
+ "noul": ("unsafe", "هل هذه الرسالة غير آمنة؟"),
298
+ "score": NO_SCORE,
299
+ },
300
+ {
301
+ "name": "Summary faithfulness (MSA)",
302
+ "state": '{"text": "افتتحت بلدية المدينة صباح الأحد خطًّا جديدًا للحافلات الكهربائية يربط وسط المدينة بالمطار، '
303
+ 'بطول 22 كيلومترًا و14 محطة. وقال رئيس البلدية إن الخط سيعمل من السادسة صباحًا حتى منتصف الليل، '
304
+ 'وإن سعر التذكرة لن يتغير خلال السنة الأولى.", '
305
+ '"summary": "افتتاح خط حافلات كهربائية جديد بين وسط المدينة والمطار"}',
306
+ "choice": NO_CHOICE,
307
+ "noul": ("faithful", "هل الملخص مأخوذ من هذا المقال؟"),
308
+ "score": NO_SCORE,
309
+ },
310
+ {
311
+ "name": "News topic (MSA)",
312
+ "state": "قرر البنك المركزي رفع سعر الفائدة الرئيسي بمقدار نصف نقطة مئوية، في خطوة تهدف إلى كبح التضخم "
313
+ "الذي بلغ أعلى مستوى له منذ عشر سنوات، وتوقع محللون أن تتراجع أسعار العقارات خلال الأشهر المقبلة.",
314
+ "choice": ("topic", "صنِّف هذا الخبر بحسب موضوعه.", TOPICS),
315
+ "noul": NO_NOUL,
316
+ "score": NO_SCORE,
317
+ },
318
+ ]
319
+
320
+
321
+ def example_row(ex):
322
+ return [ex["state"], *ex["choice"], *ex["noul"], *ex["score"]]
323
+
324
+
325
+ RAW_EXAMPLE = json.dumps({
326
+ "state": {"message": "الطلبية رقم 4471 وصلت والشاشة مكسورة. عايز أبدلها، مش عايز فلوسي ترجع.", "channel": "email"},
327
+ "model": "kev-latest",
328
+ "questions": {
329
+ "team": {"type": "choice", "instructions": "أي فريق يجب أن يتولى هذا الطلب؟",
330
+ "criteria": {"billing": "الفواتير والاسترداد", "shipping": "الشحن والتوصيل", "returns": "الاستبدال والإرجاع"}},
331
+ "refund_requested": {"type": "noul", "instructions": "هل يطلب العميل استرداد المبلغ؟"},
332
+ "frustration": {"type": "score", "instructions": "ما درجة انزعاج العميل؟",
333
+ "criteria": ["هادئ", "منزعج", "غاضب جدًا"]},
334
+ },
335
+ }, ensure_ascii=False, indent=2)
336
+
337
+
338
+ # ------------------------------------------------------------------ UI
339
+
340
+ INTRO = f"""# Jevhakim
341
+
342
+ A decision model for Arabic: Modern Standard Arabic and the dialects. It reads a **state** (a text or a JSON object)
343
+ and typed questions, and returns a probability for each option of each question in one forward pass. It does not
344
+ generate text. [Jevhakim-4B]({LINK_4B}) is more accurate; [Jevhakim-0.8B]({LINK_08B}) is smaller and faster.
345
+
346
+ <div dir="rtl">نموذج قرار للعربية الفصحى واللهجات: يقرأ نصًّا وأسئلة، ويعطي احتمالًا لكل خيار. لا يولِّد نصًّا.</div>
347
+ """
348
+
349
+ TEMPERATURES = ", ".join(f"T = {m.head.temperature:.2f} for {n}" for n, (_, m) in LOADED.items())
350
+
351
+ ABOUT = f"""Jevhakim-4B and Jevhakim-0.8B are [Kev](https://github.com/jaredpalmer/kev) (a LoRA adapter and a pointer
352
+ head on Qwen3.5-4B-Base and Qwen3.5-0.8B-Base) fine-tuned on Arabic decision data built from public labelled sources,
353
+ [TigreGotico/jevhakim-decisions]({LINK_DATA}). The tasks include dialect identification, customer-support and prompt
354
+ routing, review ratings, sentiment and emotion, offensive and hate speech, guardrail safety classes, news topics,
355
+ summary and translation faithfulness, question answering over documents, and exam questions.
356
+
357
+ There are three question types: `choice` (pick one named option), `noul` (yes or no, the answer is p(yes)) and `score`
358
+ (a level on an ordered list, the answer is the expected level). Keys (state fields, question ids, option names) are
359
+ English code identifiers and values are Arabic, as in the training data.
360
+
361
+ **Measured on the test split** (141,217 questions):
362
+
363
+ | Model | Accuracy | ECE | Brier |
364
+ | --- | --- | --- | --- |
365
+ | Jevhakim-4B | 0.765 | 0.061 | 0.328 |
366
+ | Jevhakim-0.8B | 0.661 | 0.063 | 0.438 |
367
+ | Kev-0.8B (stock) | 0.503 | | |
368
+
369
+ On the evaluation-only sources, which give no training rows (held-out synthetic sets), the accuracy is 0.727 for
370
+ Jevhakim-4B, 0.585 for Jevhakim-0.8B and 0.490 for stock Kev-0.8B.
371
+
372
+ The probabilities use the calibration temperature stored in each model's `head.pt` ({TEMPERATURES}). Measure the model
373
+ on your own data before you set a threshold.
374
+
375
+ The models do not generate text. They score the options you give them, and nothing else.
376
+
377
+ Licence CC BY-NC-SA 4.0: non-commercial research only. Several training sources are research-only. The instruction
378
+ wording is model-written Modern Standard Arabic that no native speaker has reviewed. Do not use the model for decisions
379
+ with legal, medical, financial or employment consequences without human review.
380
+ """
381
+
382
+ PLACEHOLDER = ('<div style="border:1px dashed var(--border-color-primary);border-radius:10px;padding:28px 16px;'
383
+ 'text-align:center;color:var(--body-text-color-subdued);font-size:13px">'
384
+ 'The answers appear here: one card per question, with one bar per option.</div>')
385
+
386
+ CSS = """
387
+ #col-container { max-width: 1180px; margin: 0 auto; }
388
+ #decide-btn { min-height: 44px; }
389
+ """
390
+
391
+ first = EXAMPLES[2]
392
+
393
+ with gr.Blocks(title="Jevhakim") as demo:
394
+ with gr.Column(elem_id="col-container"):
395
+ gr.Markdown(INTRO)
396
+ model_choice = gr.Radio(list(MODELS), value=DEFAULT, label="Model")
397
+ with gr.Tabs():
398
+ with gr.Tab("Questions · أسئلة"):
399
+ with gr.Row():
400
+ with gr.Column(scale=1):
401
+ state_box = gr.Textbox(label="State · الحالة (the text the model reads)", lines=5, max_lines=14,
402
+ value=first["state"], rtl=True,
403
+ placeholder="رسالة أو مراجعة أو خبر أو كائن JSON")
404
+ with gr.Group():
405
+ gr.Markdown("**Choice question** (`choice`)")
406
+ with gr.Row():
407
+ choice_id = gr.Textbox(label="Id", value=first["choice"][0], scale=1)
408
+ choice_instr = gr.Textbox(label="Instruction · التعليمة", value=first["choice"][1],
409
+ scale=3, rtl=True)
410
+ choice_opts = gr.Textbox(label="Options, one per line: name: description · الخيارات",
411
+ lines=4, value=first["choice"][2], rtl=True)
412
+ with gr.Group():
413
+ gr.Markdown("**Yes/no question** (`noul`)")
414
+ with gr.Row():
415
+ noul_id = gr.Textbox(label="Id", value=first["noul"][0], scale=1)
416
+ noul_instr = gr.Textbox(label="Instruction · التعليمة", value=first["noul"][1],
417
+ scale=3, rtl=True)
418
+ with gr.Group():
419
+ gr.Markdown("**Score question** (`score`)")
420
+ with gr.Row():
421
+ score_id = gr.Textbox(label="Id", value=first["score"][0], scale=1)
422
+ score_instr = gr.Textbox(label="Instruction · التعليمة", value=first["score"][1],
423
+ scale=3, rtl=True)
424
+ score_levels = gr.Textbox(label="Levels, lowest first, one per line · المستويات", lines=3,
425
+ value=first["score"][2], rtl=True)
426
+ gr.Markdown("Leave an id empty to leave that question out.")
427
+ run_btn = gr.Button("Decide · قرِّر", variant="primary", elem_id="decide-btn")
428
+ with gr.Column(scale=1):
429
+ answers_html = gr.HTML(value=PLACEHOLDER)
430
+ with gr.Accordion("Request sent (System One format)", open=False):
431
+ request_json = gr.JSON()
432
+ with gr.Accordion("Raw response", open=False):
433
+ response_json = gr.JSON()
434
+ inputs = [state_box, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels]
435
+ gr.Examples(examples=[example_row(ex) for ex in EXAMPLES], example_labels=[ex["name"] for ex in EXAMPLES],
436
+ inputs=inputs, label="Examples")
437
+ run_btn.click(fn=decide, inputs=[model_choice, *inputs], outputs=[answers_html, request_json, response_json], api_name="decide")
438
+
439
+ with gr.Tab("Raw JSON"):
440
+ gr.Markdown("Paste a System One request (`POST /v1/systemone`) as JSON and see the JSON response.")
441
+ with gr.Row():
442
+ with gr.Column(scale=1):
443
+ raw_in = gr.Code(label="Request (JSON)", language="json", lines=18, value=RAW_EXAMPLE)
444
+ raw_btn = gr.Button("Send", variant="primary")
445
+ with gr.Column(scale=1):
446
+ raw_status = gr.Markdown()
447
+ raw_out = gr.JSON(label="Response (JSON)")
448
+ raw_btn.click(fn=decide_raw, inputs=[model_choice, raw_in], outputs=[raw_status, raw_out], api_name="systemone")
449
+
450
+ with gr.Tab("About"):
451
+ gr.Markdown(ABOUT)
452
+
453
+ if __name__ == "__main__":
454
+ demo.queue(default_concurrency_limit=1).launch(css=CSS)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch==2.8.0
2
+ spaces
3
+ kev @ git+https://github.com/jaredpalmer/kev@1f04cfa28e6a708485b28a9585010b9e436b776e