File size: 12,586 Bytes
54c3e65
 
 
1e41561
54c3e65
f11438f
54c3e65
 
 
f11438f
54c3e65
 
 
 
 
f11438f
 
54c3e65
1e41561
54c3e65
 
 
 
 
 
 
 
 
 
 
 
 
1e41561
 
 
 
 
 
f11438f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54c3e65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f11438f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54c3e65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e41561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f11438f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54c3e65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import argparse
import io
import json
import sqlite3
import sys
from collections.abc import Sequence
from contextlib import ExitStack
from dataclasses import asdict
from pathlib import Path
from typing import TextIO

from .deberta import MODEL_ID, MODEL_REVISION, DebertaCandidateScorer
from .domain import Candidate, CandidateScorer, RerankConfig, RerankRequest, RerankResult
from .mozc import MOZC_REVISION, MozcDictionaryIndex, build_mozc_index
from .profiles import MOZC_PROFILE_SELECTION_REVISION, MOZC_PROFILES
from .reranker import Reranker
from .sidecar import serve


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="deberta-ime")
    subparsers = parser.add_subparsers(dest="command", required=True)
    rerank = subparsers.add_parser("rerank", help="rerank JSON Lines from stdin")
    rerank.add_argument("--prior-weight", type=float, default=2.0)
    rerank.add_argument("--min-margin", type=float, default=0.1)
    rerank.add_argument("--device", default="cpu")
    rerank.add_argument("--cache-dir", type=Path)
    rerank.add_argument("--offline", action="store_true")
    rerank.add_argument("--input", type=Path, help="UTF-8 JSONL file (default: stdin)")
    rerank.add_argument("--output", type=Path, help="UTF-8 JSONL file (default: stdout)")
    serve_parser = subparsers.add_parser(
        "serve", help="run a persistent UTF-8 JSON Lines reranking sidecar"
    )
    serve_parser.add_argument("--device", default="cpu")
    serve_parser.add_argument("--cache-dir", type=Path)
    serve_parser.add_argument("--offline", action="store_true")
    mozc_index = subparsers.add_parser(
        "mozc-index", help="build a local SQLite index from Mozc OSS dictionary files"
    )
    mozc_index.add_argument("--dictionary-dir", type=Path, required=True)
    mozc_index.add_argument("--output", type=Path, required=True)
    mozc_index.add_argument("--source-revision", default=MOZC_REVISION)
    mozc_rerank = subparsers.add_parser(
        "mozc-rerank", help="generate finite candidates from a Mozc index and rerank JSON Lines"
    )
    mozc_rerank.add_argument("--index", type=Path, required=True)
    mozc_rerank.add_argument("--profile", choices=tuple(MOZC_PROFILES), default="incremental")
    mozc_rerank.add_argument("--candidate-limit", type=int, default=8)
    mozc_rerank.add_argument("--device", default="cpu")
    mozc_rerank.add_argument("--cache-dir", type=Path)
    mozc_rerank.add_argument("--offline", action="store_true")
    mozc_rerank.add_argument("--input", type=Path, help="UTF-8 JSONL file (default: stdin)")
    mozc_rerank.add_argument("--output", type=Path, help="UTF-8 JSONL file (default: stdout)")
    return parser


def _request_from_payload(payload: object) -> RerankRequest:
    if not isinstance(payload, dict):
        raise ValueError("input must be a JSON object")
    reading = payload.get("reading")
    raw_candidates = payload.get("candidates")
    if not isinstance(reading, str):
        raise ValueError("reading must be a string")
    if not isinstance(raw_candidates, list):
        raise ValueError("candidates must be a list")

    candidates: list[Candidate] = []
    for raw_candidate in raw_candidates:
        if isinstance(raw_candidate, str):
            candidates.append(Candidate(raw_candidate))
            continue
        if not isinstance(raw_candidate, dict) or not isinstance(
            raw_candidate.get("surface"), str
        ):
            raise ValueError("each candidate must be a string or an object with surface")
        prior_score = raw_candidate.get("prior_score", 0.0)
        if not isinstance(prior_score, int | float):
            raise ValueError("candidate prior_score must be numeric")
        candidates.append(Candidate(raw_candidate["surface"], float(prior_score)))

    left = _string_list(payload.get("left_context", []), "left_context")
    right = _string_list(payload.get("right_context", []), "right_context")
    return RerankRequest(
        reading=reading,
        candidates=tuple(candidates),
        left_context=left,
        right_context=right,
    )


def _string_list(value: object, name: str) -> tuple[str, ...]:
    if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
        raise ValueError(f"{name} must be a list of strings")
    return tuple(value)


def _mozc_request_from_payload(
    payload: object,
    index: MozcDictionaryIndex,
    *,
    candidate_limit: int,
    profile: str,
) -> RerankRequest:
    if not isinstance(payload, dict):
        raise ValueError("input must be a JSON object")
    reading = payload.get("reading")
    if not isinstance(reading, str):
        raise ValueError("reading must be a string")
    right_context = _string_list(payload.get("right_context", []), "right_context")
    if profile == "incremental" and right_context:
        raise ValueError("incremental profile does not accept right_context")
    return RerankRequest(
        reading=reading,
        candidates=index.lookup(reading, limit=candidate_limit),
        left_context=_string_list(payload.get("left_context", []), "left_context"),
        right_context=right_context,
    )


def _result_payload(result: RerankResult) -> dict[str, object]:
    return {
        "ok": True,
        "schema_version": 1,
        "model": {"id": MODEL_ID, "revision": MODEL_REVISION},
        "reading": result.reading,
        "decision": {
            "changed": result.changed,
            "reason": result.reason,
            "margin": result.margin,
        },
        "ranked": [
            {
                "surface": item.surface,
                "original_rank": item.original_rank,
                "prior_score": item.prior_score,
                "model_score": item.model_score,
                "combined_score": item.combined_score,
            }
            for item in result.ranked
        ],
    }


def run(
    argv: Sequence[str] | None = None,
    *,
    stdin: TextIO | None = None,
    stdout: TextIO | None = None,
    stderr: TextIO | None = None,
    scorer: CandidateScorer | None = None,
) -> int:
    args = _parser().parse_args(argv)
    error_stream = stderr or sys.stderr
    if args.command == "serve":
        active_scorer = scorer or DebertaCandidateScorer(
            device=args.device,
            cache_dir=args.cache_dir,
            local_files_only=args.offline,
        )
        input_stream = stdin
        if input_stream is None:
            if isinstance(sys.stdin, io.TextIOWrapper):
                sys.stdin.reconfigure(encoding="utf-8", errors="strict")
            input_stream = sys.stdin
        output_stream = stdout
        if output_stream is None:
            if isinstance(sys.stdout, io.TextIOWrapper):
                sys.stdout.reconfigure(
                    encoding="utf-8",
                    errors="strict",
                    newline="\n",
                    write_through=True,
                )
            output_stream = sys.stdout
        return serve(input_stream, output_stream, active_scorer)
    if args.command == "mozc-index":
        try:
            manifest = build_mozc_index(
                args.dictionary_dir,
                args.output,
                source_revision=args.source_revision,
            )
        except (OSError, ValueError, sqlite3.Error) as error:
            error_stream.write(f"error: {error}\n")
            return 2
        output_stream = stdout or sys.stdout
        output_stream.write(
            json.dumps(
                {"ok": True, "schema_version": 1, "manifest": asdict(manifest)},
                ensure_ascii=False,
                separators=(",", ":"),
            )
        )
        output_stream.write("\n")
        return 0
    if args.command == "mozc-rerank":
        if (
            args.input is not None
            and args.output is not None
            and args.input.resolve() == args.output.resolve()
        ):
            error_stream.write("error: --input and --output must be different paths\n")
            return 2
        active_scorer = scorer or DebertaCandidateScorer(
            device=args.device,
            cache_dir=args.cache_dir,
            local_files_only=args.offline,
        )
        selected_profile = MOZC_PROFILES[args.profile]
        reranker = Reranker(active_scorer, selected_profile)
        try:
            index = MozcDictionaryIndex(args.index)
        except (OSError, ValueError, sqlite3.Error) as error:
            error_stream.write(f"error: cannot open Mozc index: {error}\n")
            return 2
        with index, ExitStack() as stack:
            input_stream = (
                stack.enter_context(args.input.open(encoding="utf-8"))
                if args.input is not None
                else (stdin or sys.stdin)
            )
            output_stream = (
                stack.enter_context(args.output.open("w", encoding="utf-8", newline="\n"))
                if args.output is not None
                else (stdout or sys.stdout)
            )
            exit_code = 0
            for line_number, raw_line in enumerate(input_stream, start=1):
                if not raw_line.strip():
                    continue
                try:
                    request = _mozc_request_from_payload(
                        json.loads(raw_line),
                        index,
                        candidate_limit=args.candidate_limit,
                        profile=args.profile,
                    )
                    payload = _result_payload(reranker.rerank(request))
                    payload["candidate_source"] = {
                        "kind": "mozc_oss_dictionary_index",
                        "source_revision": index.manifest.source_revision,
                        "limit": args.candidate_limit,
                    }
                    payload["profile"] = args.profile
                    payload["profile_config"] = {
                        "prior_weight": selected_profile.prior_weight,
                        "min_margin": selected_profile.min_margin,
                        "selected_on_revision": MOZC_PROFILE_SELECTION_REVISION,
                    }
                except (json.JSONDecodeError, ValueError, TypeError) as error:
                    payload = {
                        "ok": False,
                        "schema_version": 1,
                        "line": line_number,
                        "error": str(error),
                    }
                    exit_code = 2
                output_stream.write(
                    json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
                )
                output_stream.write("\n")
            return exit_code
    if (
        args.input is not None
        and args.output is not None
        and args.input.resolve() == args.output.resolve()
    ):
        error_stream.write("error: --input and --output must be different paths\n")
        return 2
    active_scorer = scorer or DebertaCandidateScorer(
        device=args.device,
        cache_dir=args.cache_dir,
        local_files_only=args.offline,
    )
    reranker = Reranker(
        active_scorer,
        RerankConfig(prior_weight=args.prior_weight, min_margin=args.min_margin),
    )
    with ExitStack() as stack:
        input_stream = (
            stack.enter_context(args.input.open(encoding="utf-8"))
            if args.input is not None
            else (stdin or sys.stdin)
        )
        output_stream = (
            stack.enter_context(args.output.open("w", encoding="utf-8", newline="\n"))
            if args.output is not None
            else (stdout or sys.stdout)
        )
        exit_code = 0
        for line_number, raw_line in enumerate(input_stream, start=1):
            if not raw_line.strip():
                continue
            try:
                request = _request_from_payload(json.loads(raw_line))
                payload = _result_payload(reranker.rerank(request))
            except (json.JSONDecodeError, ValueError, TypeError) as error:
                payload = {
                    "ok": False,
                    "schema_version": 1,
                    "line": line_number,
                    "error": str(error),
                }
                exit_code = 2
            output_stream.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
            output_stream.write("\n")
        return exit_code


def main() -> None:
    raise SystemExit(run())


if __name__ == "__main__":
    main()