File size: 15,323 Bytes
1323995
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
"""Reading Jais-ASR output: the leading `[...]` decision tag.

Every generation starts with a bracketed decision:

    "[EG] النص هنا"        ->  a transcript, in Egyptian Arabic
    "[msa] النص هنا"       ->  a transcript, in Modern Standard Arabic
    "[IFM-NoTranscript]"   ->  a deliberate abstention: no transcribable speech

The rule is general: the transcript never begins with a bracketed span. Anything the
model writes inside leading `[...]` spans is decision metadata, routed to `.tag` (and
to the API's `reasoning` field), never into `.text`:

  * a span whose body opens with 'IF' (any case) is the refusal branch: `.refused`
    is True and `.text` is empty -- nothing after a refusal is a transcript.
  * otherwise the first alphabetic component that case-folds onto a known tag sets
    `.language`; a span naming no supported language leaves `.language` as None.
  * no leading span -> `.tag` is None and `.text` is the whole output, untouched.

    from postprocess import split_text

    r = split_text(raw)
    r.text        # the transcript
    r.language    # canonical tag ("EG", "msa", ...) or None
    r.refused     # True when the model abstained
    r.tag         # the leading tag as emitted, e.g. "[EG]", or None

For well-formed output the exact token-id fast path (`split_token_ids`) gives the same
answer with no string work; the text rule is the authority otherwise.

This module is pure python: no torch, no transformers, no vllm at import time.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

__all__ = [
    "LANGUAGE_TAGS",
    "REFUSAL_TAG",
    "Transcription",
    "split_text",
    "split_token_ids",
    "classify_tag_body",
    "stream_split",
    "verify_against_tokenizer",
]

# ---------------------------------------------------------------------------------
# The decision inventory. Token ids are those of this repo's tokenizer;
# `verify_against_tokenizer` below re-derives every one of them.
# ---------------------------------------------------------------------------------
BRACKET_OPEN_ID = 60  # '['
BRACKET_CLOSE_ID = 62  # ']'
THINK_OPEN_ID = 250029  # <ifm|think>
THINK_CLOSE_ID = 250030  # '</ifm|think>', the last token of every rendered prompt

REFUSAL_TAG = "[IFM-NoTranscript]"
REFUSAL_TOKEN_IDS = (
    60,
    5126,
    46,
    131126,
    195862,
    62,
)  # '[' 'IF' 'M' '-No' 'Transcript' ']'

#: decision-token id -> tag surface. 24 tags, all distinct.
LANGUAGE_TAG_IDS = {
    226606: "msa",  # Modern Standard Arabic
    272: "en",
    2075: "fr",
    273: "es",
    286: "it",
    7646: "EG",
    8048: "SA",
    6101: "MA",
    29354: "IQ",
    23129: "JO",
    20056: "SY",
    27526: "QA",
    64489: "DZ",
    23852: "LB",
    5594: "OM",
    28472: "YE",
    43852: "TN",
    17604: "MR",
    25893: "KW",
    9373: "SD",
    12933: "LY",
    5402: "AE",
    4295: "PS",
    41905: "BH",
}
LANGUAGE_TAGS = tuple(LANGUAGE_TAG_IDS.values())

#: longest well-formed prefix, in tokens -- the id fast path never looks further
MAX_TAG_TOKENS = len(REFUSAL_TOKEN_IDS)

#: a decision span is SHORT. 64 chars is far beyond any real tag; anything longer
#: is not tag-shaped and stays in the transcript untouched.
TAG_SPAN_MAX_CHARS = 64
#: total leading-metadata budget (a few stacked spans at most)
TAG_ZONE_MAX_CHARS = 160

# one bracketed span (no newline inside; the first ']' closes it); used with .match(pos)
_SPAN_RE = re.compile(r"[ \t]*\[([^\]\n]{0,%d})\]" % TAG_SPAN_MAX_CHARS)

_CANONICAL = {t.lower(): t for t in LANGUAGE_TAGS}


@dataclass(frozen=True)
class Transcription:
    """What the model decided, and what it transcribed."""

    text: str
    language: str | None = None
    refused: bool = False
    tag: str | None = None
    thinking: str = ""
    truncated: bool = False

    def __bool__(self) -> bool:  # `if result:` is False on an abstention
        return not self.refused and bool(self.text)


def has_prefilled_tag(prompt_ids):
    """Only a complete known [language] suffix counts as a prompt prefill."""
    return (
        len(prompt_ids) >= 3
        and int(prompt_ids[-3]) == BRACKET_OPEN_ID
        and int(prompt_ids[-2]) in LANGUAGE_TAG_IDS
        and int(prompt_ids[-1]) == BRACKET_CLOSE_ID
    )


def classify_tag_body(body: str):
    """(language | None, refused) for one span body. See the module contract."""
    b = body.strip()
    if b[:2].upper() == "IF":
        return None, True
    for comp in re.split(r"[^A-Za-z]+", b):
        if comp and comp.lower() in _CANONICAL:
            return _CANONICAL[comp.lower()], False
    return None, False


def _leading_spans(text: str):
    """Parse the leading metadata zone -> (spans, refused, language, end_pos)."""
    spans, refused, language, pos = [], False, None, 0
    while pos <= TAG_ZONE_MAX_CHARS:
        m = _SPAN_RE.match(text, pos)
        if not m:
            break
        body = m.group(1)
        spans.append(f"[{body}]")
        lang, ref = classify_tag_body(body)
        refused = refused or ref
        if language is None and lang is not None:
            language = lang
        pos = m.end()
    return spans, refused, language, pos


# ---------------------------------------------------------------------------------
# token-id path -- exact fast path for WELL-FORMED output
# ---------------------------------------------------------------------------------
def tag_span_len(token_ids) -> int:
    """Tokens spanned by a WELL-FORMED leading tag, else 0 (the text rule then decides)."""
    ids = list(token_ids[:MAX_TAG_TOKENS])
    if not ids or ids[0] != BRACKET_OPEN_ID:
        return 0
    if tuple(ids[: len(REFUSAL_TOKEN_IDS)]) == REFUSAL_TOKEN_IDS:
        return len(REFUSAL_TOKEN_IDS)
    if len(ids) >= 3 and ids[1] in LANGUAGE_TAG_IDS and ids[2] == BRACKET_CLOSE_ID:
        return 3
    return 0


def split_token_ids(token_ids, *, strip: bool = True):
    """-> (tag or None, refused, remaining_token_ids). Exact-form fast path only;
    anything else returns (None, False, ids) and callers fall back to `split_text`."""
    ids = list(token_ids)
    n = tag_span_len(ids)
    if n == 0:
        return None, False, ids
    if n == len(REFUSAL_TOKEN_IDS):
        return REFUSAL_TAG, True, ([] if strip else ids)
    return f"[{LANGUAGE_TAG_IDS[ids[1]]}]", False, (ids[n:] if strip else ids)


# ---------------------------------------------------------------------------------
# text path -- the authority
# ---------------------------------------------------------------------------------
def _split_think(text: str):
    """Generated-side think block (enable_thinking=True): the prompt opens
    '<ifm|think>', so the generation carries thought text up to the first
    '</ifm|think>'. Without that marker this is a no-op."""
    head = text.lstrip()
    if head.startswith("<ifm|think>"):
        text = head[len("<ifm|think>") :]
    idx = text.find("</ifm|think>")
    if idx < 0:
        return "", text
    return text[:idx].strip(), text[idx + len("</ifm|think>") :]


def split_text(text: str, *, strip: bool = True) -> Transcription:
    """Split a decoded generation per the general contract (module docstring)."""
    thinking, text = _split_think(text)
    spans, refused, language, pos = _leading_spans(text)
    if not spans:
        return Transcription(text=text, thinking=thinking)
    tag = " ".join(spans)
    if refused:
        return Transcription(
            text="" if strip else text, refused=True, tag=tag, thinking=thinking
        )
    rest = text[pos:].lstrip() if strip else text
    return Transcription(text=rest, language=language, tag=tag, thinking=thinking)


def stream_split(text: str):
    """Streaming view of the contract over the text-so-far.

    -> (reasoning_so_far, content_so_far, holding)

    * while the head could still be inside the metadata zone (only whitespace yet, or
      a short unclosed '[' span) the undecided tail is HELD (both strings frozen);
    * a refusal-branch output puts its closed leading span(s) in reasoning; nothing
      after a refusal is ever content;
    * otherwise reasoning is the closed leading span(s), content starts at the first
      non-metadata character. Both returned strings only ever grow (delta-safe).
    """
    spans, refused, _lang, pos = _leading_spans(text)
    if refused:
        return text[:pos], "", False
    rest = text[pos:]
    head = rest.lstrip(" \t")
    ws = len(rest) - len(head)
    if not spans:
        if head == "" and len(text) <= TAG_ZONE_MAX_CHARS:
            return "", "", True  # nothing decisive yet
        if (
            head.startswith("[")
            and "]" not in head
            and "\n" not in head
            and len(head) <= TAG_SPAN_MAX_CHARS + 2
        ):
            return "", "", True  # a span may be forming
        return "", text, False  # no metadata: all content
    if (
        head.startswith("[")
        and "]" not in head
        and "\n" not in head
        and len(head) <= TAG_SPAN_MAX_CHARS + 2
        and pos + ws <= TAG_ZONE_MAX_CHARS
    ):
        return text[:pos], "", True  # another span may be forming
    return text[:pos], text[pos + ws :], False


def verify_against_tokenizer(tokenizer) -> None:
    """Assert the id table matches this tokenizer. Cheap; call it in your tests."""
    enc = lambda s: tokenizer(s, add_special_tokens=False).input_ids  # noqa: E731
    assert (
        tuple(enc(REFUSAL_TAG)) == REFUSAL_TOKEN_IDS
    ), f"{REFUSAL_TAG} -> {enc(REFUSAL_TAG)}, expected {list(REFUSAL_TOKEN_IDS)}"
    assert tuple(enc("</ifm|think>")) == (
        THINK_CLOSE_ID,
    ), f"</ifm|think> -> {enc('</ifm|think>')}, expected [{THINK_CLOSE_ID}]"
    for tid, tag in LANGUAGE_TAG_IDS.items():
        ids = enc(f"[{tag}] ")
        assert (
            ids[0] == BRACKET_OPEN_ID and ids[1] == tid
        ), f"[{tag}] -> {ids[:3]}, expected decision id {tid} at position 1"
    assert len(set(LANGUAGE_TAG_IDS)) == 24


# =================================================================================
# vLLM integration -- metadata becomes `message.reasoning`, transcript is `content`
#
#   vllm serve <model> --trust-remote-code --reasoning-parser jais_asr_tag
#
# Registration happens on import inside a vLLM process; the vllm_plugin/ package in
# this repo does that automatically at server start.
# =================================================================================
try:  # pragma: no cover - only meaningful inside a vLLM process
    from vllm.reasoning.abs_reasoning_parsers import (
        ReasoningParser,
        ReasoningParserManager,
    )
    from vllm.entrypoints.openai.engine.protocol import DeltaMessage

    @ReasoningParserManager.register_module("jais_asr_tag")
    class JaisASRTagParser(ReasoningParser):
        """reasoning = the leading decision metadata; content = the transcript only.

        Same general contract as split_text: any leading bracketed span(s) go to
        `reasoning`; a refusal-branch output puts its leading tag in `reasoning`
        (the same value as `.tag`) with empty content; no leading span -> empty
        reasoning.
        """

        def __init__(self, tokenizer, *args, **kwargs):
            template = kwargs.get("chat_template_kwargs") or {}
            self.language_prefill = template.get("language_prefill")
            self._prefill_emitted = False
            super().__init__(tokenizer, *args, **kwargs)

        # ReasoningConfig auto-initialization tokenizes these at server start;
        # they are the prompt's think-block delimiters, already closed in every
        # rendered prompt, so the thinking-budget machinery stays inert.
        @property
        def reasoning_start_str(self) -> str:
            return "<ifm|think>"

        @property
        def reasoning_end_str(self) -> str:
            return "</ifm|think>"

        def is_reasoning_end(self, input_ids) -> bool:
            ids = list(input_ids)
            # vLLM also calls this with PROMPT ids (seeding per-request state) and
            # with prompt+output ids; every rendered prompt ends with '</ifm|think>'
            # (+ an optional prefill), so only what follows the LAST one is output.
            for i in range(len(ids) - 1, -1, -1):
                if ids[i] == THINK_CLOSE_ID:
                    ids = ids[i + 1 :]
                    break
            else:
                if ids and ids[0] == 0:  # BOS-led, no anchor: a bare prompt
                    return False
            text = self.model_tokenizer.decode(ids[:48])
            _r, content, holding = stream_split(text)
            return (not holding) and content != ""

        def extract_content_ids(self, input_ids):
            ids = list(input_ids)
            text = self.model_tokenizer.decode(ids)
            r = split_text(text)
            if r.refused:
                return []
            if r.tag is None:
                return ids
            # the space after ']' merges into the first content token, so keep the
            # boundary token: cut after the last prefix that fits INSIDE the tag zone
            cut = len(text) - len(r.text)
            keep = 0
            for k in range(1, len(ids) + 1):
                if len(self.model_tokenizer.decode(ids[:k])) > cut:
                    break
                keep = k
            return ids[keep:]

        def extract_reasoning(self, model_output, request):
            r = split_text(model_output)
            tag = r.tag
            if tag is None and not r.refused:
                kw = getattr(request, "chat_template_kwargs", None) or {}
                if kw.get("language_prefill"):
                    tag = f"[{kw['language_prefill']}]"  # tag was prefilled into the prompt
            reasoning = "\n".join(p for p in (r.thinking, tag or "") if p) or None
            if r.refused:
                return reasoning, ""
            if tag is None and not r.thinking:
                return None, model_output
            return reasoning, r.text

        def extract_reasoning_streaming(
            self,
            previous_text,
            current_text,
            delta_text,
            previous_token_ids,
            current_token_ids,
            delta_token_ids,
        ):
            pr, pc, _ = stream_split(previous_text)
            cr, cc, _holding = stream_split(current_text)
            dr = cr[len(pr) :] if cr.startswith(pr) else cr
            dc = cc[len(pc) :] if cc.startswith(pc) else cc
            if self.language_prefill and not self._prefill_emitted:
                dr = f"[{self.language_prefill}]" + ("\n" + dr if dr else "")
                self._prefill_emitted = True
            if not dr and not dc:
                return None
            return DeltaMessage(reasoning=dr or None, content=dc or None)

    __all__.append("JaisASRTagParser")
except (
    Exception
) as exc:  # vLLM absent, or an API shift -- the pure-python half still works
    import importlib.util

    if importlib.util.find_spec("vllm") is not None:
        import logging

        logging.getLogger(__name__).warning(
            "jais_asr_tag reasoning parser NOT registered (%s: %s); "
            "`--reasoning-parser jais_asr_tag` will fail with a KeyError",
            type(exc).__name__,
            exc,
        )