File size: 11,593 Bytes
0f54f14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
406
407
408
#!/usr/bin/env python3
"""Inference CLI for foksly/wmt26-constrained-submission."""

import argparse
import sys
import time
from pathlib import Path
from typing import Dict, Optional

import torch
import transformers
from packaging.version import Version
from transformers import AutoModelForCausalLM, AutoTokenizer


MODEL_ID = "foksly/wmt26-constrained-submission"
MIN_TRANSFORMERS_VERSION = Version("4.46.3")


# Public CLI uses short ISO 639-1 codes.
# WMT and ISO 639-2/3 aliases are accepted for convenience.
TARGET_ALIASES: Dict[str, str] = {
    # Russian
    "ru": "ru",
    "rus": "ru",
    "rus_cyrl": "ru",

    # Belarusian
    "be": "be",
    "bel": "be",
    "bel_cyrl": "be",

    # Kazakh
    "kk": "kk",
    "kaz": "kk",
    "kaz_cyrl": "kk",

    # Armenian
    "hy": "hy",
    "hye": "hy",
    "hye_armn": "hy",
}


TRANSLATION_PROMPTS: Dict[str, str] = {
    "ru": "Текст для перевода на русский:",
    "be": "Текст для перевода на белорусский:",
    "kk": "Текст для перевода на казахский:",
    "hy": "Текст для перевода на армянский:",
}


DOMAIN_PROMPTS: Dict[str, str] = {
    "social": (
        "Ты профессиональный переводчик. "
        "Исходный текст — публикация или комментарий из социальных сетей. "
        "Переводи в естественном разговорном стиле. "
        "Не повторяй орфографические ошибки. "
        "Сохраняй ссылки, ники, HTML-разметку и пунктуацию. "
        "Переводи хештеги так, чтобы они естественно звучали "
        "на целевом языке. "
        "В ответе возвращай только перевод."
    ),
    "speech": (
        "Ты профессиональный переводчик. "
        "Исходный текст автоматически расшифрован с устной речи "
        "и может содержать ошибки. "
        "Сохраняй разговорный стиль. "
        "Не включай нелингвистические звуки, но оставляй междометия. "
        "Если слово оборвано, восстанови его, если это возможно, "
        "иначе пропусти. "
        "Каждое предложение выводи с новой строки. "
        "В ответе возвращай только перевод."
    ),
    "news": (
        "Ты профессиональный переводчик. "
        "Исходный текст — новостная статья. "
        "Переводи в официальном журналистском стиле. "
        "Сохраняй HTML-разметку. "
        "В ответе возвращай только перевод."
    ),
    "software": (
        "Ты профессиональный переводчик. "
        "Исходный текст содержит структурированные данные "
        "программного обеспечения. "
        "Переводи только текстовые значения. "
        "Не изменяй ключи, плейсхолдеры, переменные "
        "и служебную разметку. "
        "Верни результат в том же формате, что и исходный текст. "
        "В ответе возвращай только перевод."
    ),
}


def log(message: str, quiet: bool = False) -> None:
    """Write diagnostic messages to stderr."""
    if not quiet:
        print(message, file=sys.stderr, flush=True)


def normalize_target(target: str) -> str:
    """Convert a user-provided language alias to the canonical short code."""
    normalized = target.strip().lower()

    if normalized not in TARGET_ALIASES:
        supported = "ru, be, kk, hy"
        raise ValueError(
            f"Unsupported target language: {target!r}. "
            f"Use one of: {supported}."
        )

    return TARGET_ALIASES[normalized]


def select_dtype(dtype_name: str) -> torch.dtype:
    """Select an inference dtype suitable for the current hardware."""
    explicit_dtypes = {
        "float16": torch.float16,
        "bfloat16": torch.bfloat16,
        "float32": torch.float32,
    }

    if dtype_name in explicit_dtypes:
        return explicit_dtypes[dtype_name]

    if torch.cuda.is_available():
        supports_bf16 = getattr(
            torch.cuda,
            "is_bf16_supported",
            lambda: False,
        )

        if supports_bf16():
            return torch.bfloat16

        # V100 and other older CUDA GPUs.
        return torch.float16

    return torch.float32


def read_source_text(text: Optional[str]) -> str:
    """Read source text from --text or stdin."""
    source = text if text is not None else sys.stdin.read()

    if not source.strip():
        raise ValueError(
            "Source text is empty. "
            "Pass it through --text or stdin."
        )

    # Do not strip the source itself: leading whitespace and formatting
    # may be meaningful for software and HTML inputs.
    return source


def read_custom_prompt(
    prompt: Optional[str],
    prompt_file: Optional[str],
) -> Optional[str]:
    """Read a custom instruction from CLI or a UTF-8 text file."""
    if prompt is not None:
        custom_prompt = prompt
    elif prompt_file is not None:
        custom_prompt = Path(prompt_file).read_text(encoding="utf-8")
    else:
        return None

    custom_prompt = custom_prompt.strip()

    if not custom_prompt:
        raise ValueError("Custom prompt is empty.")

    return custom_prompt


def build_prompt(
    source_text: str,
    target: str,
    domain: Optional[str] = None,
    custom_prompt: Optional[str] = None,
) -> str:
    """Construct the exact plain-text prompt passed to the model."""
    parts = []

    if custom_prompt is not None:
        parts.append(custom_prompt)
    elif domain is not None:
        parts.append(DOMAIN_PROMPTS[domain])

    parts.append(TRANSLATION_PROMPTS[target])

    # Single newline between instruction blocks mirrors the WMT setup.
    return "\n".join(parts) + "\n" + source_text


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Translate English text using the "
            "WMT26 constrained submission model."
        )
    )

    parser.add_argument(
        "--target",
        required=True,
        metavar="LANG",
        help=(
            "Target language: ru, be, kk, or hy. "
            "Aliases such as rus, bel, kaz, hye and WMT codes "
            "are also accepted."
        ),
    )

    parser.add_argument(
        "--text",
        help=(
            "English source text. "
            "If omitted, the source is read from stdin."
        ),
    )

    instruction_group = parser.add_mutually_exclusive_group()

    instruction_group.add_argument(
        "--domain",
        choices=sorted(DOMAIN_PROMPTS),
        help=(
            "WMT26 domain prompt: "
            "social, speech, news, or software."
        ),
    )

    instruction_group.add_argument(
        "--prompt",
        help=(
            "Custom instruction. It replaces the domain prompt; "
            "the target-language prompt is still appended automatically."
        ),
    )

    instruction_group.add_argument(
        "--prompt-file",
        help=(
            "Path to a UTF-8 file containing a custom instruction. "
            "It replaces the domain prompt."
        ),
    )

    parser.add_argument(
        "--model",
        default=MODEL_ID,
        help="Hugging Face repo ID or local model directory.",
    )

    parser.add_argument(
        "--max-new-tokens",
        type=int,
        default=2048,
        help="Maximum number of generated tokens. Default: 2048.",
    )

    parser.add_argument(
        "--dtype",
        choices=("auto", "float16", "bfloat16", "float32"),
        default="auto",
        help=(
            "Model dtype. Auto uses BF16 when supported, "
            "FP16 on older CUDA GPUs, and FP32 on CPU."
        ),
    )

    parser.add_argument(
        "--show-prompt",
        action="store_true",
        help=(
            "Print the final prompt and exit without loading the model."
        ),
    )

    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress progress messages on stderr.",
    )

    return parser.parse_args()


def main() -> None:
    args = parse_args()

    if Version(transformers.__version__) < MIN_TRANSFORMERS_VERSION:
        raise RuntimeError(
            "transformers>={} is required; found {}.\n"
            "Upgrade it with:\n"
            "python -m pip install -U "
            "'transformers>=4.46.3,<5'".format(
                MIN_TRANSFORMERS_VERSION,
                transformers.__version__,
            )
        )

    if args.max_new_tokens <= 0:
        raise ValueError("--max-new-tokens must be positive.")

    target = normalize_target(args.target)
    source_text = read_source_text(args.text)
    custom_prompt = read_custom_prompt(
        prompt=args.prompt,
        prompt_file=args.prompt_file,
    )

    prompt = build_prompt(
        source_text=source_text,
        target=target,
        domain=args.domain,
        custom_prompt=custom_prompt,
    )

    if args.show_prompt:
        print(prompt)
        return

    dtype = select_dtype(args.dtype)

    log("[1/3] Loading tokenizer...", args.quiet)

    tokenizer = AutoTokenizer.from_pretrained(
        args.model,
        use_fast=False,
    )

    log(
        f"[2/3] Loading model with dtype={dtype}...",
        args.quiet,
    )
    started = time.time()

    model = AutoModelForCausalLM.from_pretrained(
        args.model,
        torch_dtype=dtype,
        device_map="auto",
        low_cpu_mem_usage=True,
    )
    model.eval()

    log(
        f"[2/3] Model loaded in {time.time() - started:.1f}s.",
        args.quiet,
    )

    encoded = tokenizer(
        prompt,
        return_tensors="pt",
    )

    input_device = model.get_input_embeddings().weight.device
    inputs = {
        name: tensor.to(input_device)
        for name, tensor in encoded.items()
    }

    input_length = inputs["input_ids"].shape[1]

    pad_token_id = tokenizer.pad_token_id
    if pad_token_id is None:
        pad_token_id = tokenizer.eos_token_id

    log("[3/3] Generating translation...", args.quiet)
    started = time.time()

    with torch.inference_mode():
        output_ids = model.generate(
            **inputs,
            max_new_tokens=args.max_new_tokens,
            do_sample=False,
            num_beams=1,
            eos_token_id=tokenizer.eos_token_id,
            pad_token_id=pad_token_id,
        )

    generated_ids = output_ids[0, input_length:]

    translation = tokenizer.decode(
        generated_ids,
        skip_special_tokens=True,
    ).strip()

    log(
        f"[3/3] Done in {time.time() - started:.1f}s.",
        args.quiet,
    )

    if not translation:
        raise RuntimeError(
            "The model generated an empty translation."
        )

    # stdout contains only the translation.
    print(translation)


if __name__ == "__main__":
    main()