import 'dart:convert'; /// NLLB-200 토크나이저 (Hugging Face tokenizer.json 의 BPE + Metaspace 를 Dart 로 구현) /// /// - 입력: `[원어 코드] 토큰들 ` /// - 출력(디코더 시작): ` [대상 언어 코드]` class NllbTokenizer { static const meta = '▁'; // ▁ static const padId = 1, eosId = 2, unkId = 3; final Map _vocab; final List _idToToken; final Map _mergeRank; final Map _special; // 언어 코드 등 final Map> _cache = {}; NllbTokenizer._(this._vocab, this._idToToken, this._mergeRank, this._special); /// tokenizer.json 내용으로 생성 (16MB 이므로 별도 isolate 에서 호출 권장) factory NllbTokenizer.fromJson(String jsonText) { final t = jsonDecode(jsonText) as Map; final model = t['model'] as Map; if (model['type'] != 'BPE') throw FormatException('BPE 토크나이저가 아닙니다: ${model['type']}'); final vocab = {}; final rawVocab = model['vocab']; if (rawVocab is Map) { rawVocab.forEach((k, v) => vocab[k as String] = v as int); } else { for (final e in rawVocab as List) { vocab[e[0] as String] = e[1] as int; } } final special = {}; for (final a in (t['added_tokens'] as List)) { final m = a as Map; special[m['content'] as String] = m['id'] as int; vocab[m['content'] as String] = m['id'] as int; } var maxId = 0; for (final v in vocab.values) { if (v > maxId) maxId = v; } final idToToken = List.filled(maxId + 1, ''); vocab.forEach((k, v) => idToToken[v] = k); final ranks = {}; final merges = model['merges'] as List; for (var i = 0; i < merges.length; i++) { final m = merges[i]; // "a b" 문자열 또는 ["a","b"] 배열 형식 모두 지원 final key = m is String ? m : '${m[0]} ${m[1]}'; ranks[key] = i; } return NllbTokenizer._(vocab, idToToken, ranks, special); } int get vocabSize => _idToToken.length; /// 언어 코드 토큰 번호 (예: kor_Hang) int langId(String nllbCode) { final id = _special[nllbCode]; if (id == null) throw ArgumentError('NLLB 언어 코드가 아닙니다: $nllbCode'); return id; } bool isSpecial(int id) => id < 4 || _special.containsValue(id) && id >= 256000; /// 문장 → 인코더 입력 번호 `[원어] ... ` List encode(String text, String srcLang, {int maxTokens = 256}) { final ids = [langId(srcLang)]; for (final word in _preTokenize(_normalize(text))) { ids.addAll(_bpe(word)); if (ids.length >= maxTokens - 1) break; } if (ids.length > maxTokens - 1) ids.length = maxTokens - 1; ids.add(eosId); return ids; } /// 번호 → 문장 (특수 토큰 제외) String decode(Iterable ids) { final sb = StringBuffer(); for (final id in ids) { if (id < 0 || id >= _idToToken.length) continue; if (id <= unkId || id >= 256001) continue; // 특수·언어 코드 sb.write(_idToToken[id]); } return sb.toString().replaceAll(meta, ' ').trim(); } /// 간단한 정규화: 제어 문자 제거, 공백 정리, 전각 영숫자·기호 → 반각 static String _normalize(String s) { final out = StringBuffer(); for (final r in s.runes) { if (r < 0x20 && r != 0x0A && r != 0x09) continue; if (r >= 0xFF01 && r <= 0xFF5E) { out.writeCharCode(r - 0xFEE0); } else if (r == 0x3000) { out.write(' '); } else { out.writeCharCode(r); } } return out.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); } /// Metaspace: 공백을 ▁ 로 바꾸고 앞에 ▁ 를 붙인 뒤, ▁ 앞에서 나눔 static List _preTokenize(String s) { if (s.isEmpty) return const []; final replaced = meta + s.replaceAll(' ', meta); final words = []; var start = 0; for (var i = 1; i < replaced.length; i++) { if (replaced[i] == meta) { words.add(replaced.substring(start, i)); start = i; } } words.add(replaced.substring(start)); return words; } List _bpe(String word) { final cached = _cache[word]; if (cached != null) return cached; // 유니코드 문자 단위로 시작 var parts = [for (final r in word.runes) String.fromCharCode(r)]; while (parts.length > 1) { var best = -1; var bestRank = 1 << 62; for (var i = 0; i < parts.length - 1; i++) { final r = _mergeRank['${parts[i]} ${parts[i + 1]}']; if (r != null && r < bestRank) { bestRank = r; best = i; } } if (best < 0) break; final a = parts[best], b = parts[best + 1]; // 같은 쌍을 한 번에 모두 병합 final merged = []; for (var i = 0; i < parts.length; i++) { if (i < parts.length - 1 && parts[i] == a && parts[i + 1] == b) { merged.add(a + b); i++; } else { merged.add(parts[i]); } } parts = merged; } final ids = []; var lastUnk = false; for (final t in parts) { final id = _vocab[t]; if (id == null) { if (!lastUnk) ids.add(unkId); // fuse_unk lastUnk = true; } else { ids.add(id); lastUnk = false; } } if (_cache.length < 50000) _cache[word] = ids; return ids; } }