Skip to content

Commit 7747e2a

Browse files
togatogaclaude
andauthored
feat: 学習履歴のsurface最大文字数制限とCtrl+Delでの履歴削除を追加 (#64)
* feat: 学習履歴のsurface最大文字数制限とCtrl+Delでの履歴削除を追加 Mozcの実装を参考に学習履歴まわりを拡張する。 - 変換結果(surface)が max_surface_chars(既定50文字)を超える確定は 学習しない。ライブ変換の文全体確定で1文まるごとのエントリが キャッシュを圧迫するのを防ぐ(Mozc: kMaxStringLength相当) - 変換中に学習候補を選択して Ctrl+Del でそのエントリを履歴から削除 (Mozc: DeleteSelectedCandidate。ClearHistoryEntry + ConvertCancel と 同じく完全一致で削除して入力状態に戻る) - 学習候補の選択中はauxテキスト(候補ウィンドウのフッター)に 「Ctrl+Delで履歴から削除」を表示(Mozc: FillFooter と同文言) - いずれも config.toml の [learning] で設定可能 (max_surface_chars = 50) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: macOSのdeleteキーでも学習履歴を削除できるようにし、削除後も候補ウィンドウを維持する - Macキーボードの「delete」はBackspace(XK_BackSpace)なので、 Ctrl+Delete(forward delete)に加えて Ctrl+Backspace でも 学習履歴を削除できるようにする。素のBackspaceは従来どおり 変換キャンセル - 削除成功時、Mozcは ConvertCancel で候補ウィンドウが一瞬閉じて 再表示されるが、karukanはウィンドウを開いたまま該当候補だけを リストからその場で取り除く(選択位置は維持、再推論なし)。 リストが空になった場合のみMozc同様キャンセルにフォールバック Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: 履歴削除の注釈をCtrl+Backspace表記に変更 実際に押すキーはどちらのOSでもBackspace位置のキー (macOSの「delete」キーの実体もBackspace)なので、 forward deleteと紛らわしいMozc流の「Ctrl+Del」表記をやめて 「Ctrl+Backspaceで履歴から削除」に統一する。 Ctrl+Delete(forward delete)のバインドは引き続き有効。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: 学習候補削除のバグ修正とmozc風の挙動統一 コードレビューで見つかった学習履歴削除まわりの不具合を修正。 - 削除後に候補をその場で消すのをやめ、mozcのcancel-and-reconvertと 同様に変換を作り直す。これによりモデル/辞書/フォールバックも出す 表層(dedupで隠れていた分)が通常候補として復活する - 履歴削除を接頭辞対応(remove_suggestion)にし、同一表層のprefix双子を まとめて削除。次変換での復活を防ぐ - Ctrl+Delete/Ctrl+Backspaceは、選択が学習候補でなければ何もしない (mozcのDoNothing)に統一。キャンセルは素のBackspace/Escapeが担う - Ctrl+Alt+Delete/Backspaceは履歴を消さずアプリへ素通り - 入力中(Composing)のサジェスト窓でもCtrl+Backspaceで先頭の学習候補を 削除できるようにし、文字が消える不具合を修正 - max_surface_charsの設定→キャッシュ配線に回帰テストを追加 あわせて候補生成のマッピング共通化などのクリーンアップを実施。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: 候補ソースの一元化とLearningConfig導入 機能変更なしのリファクタリング(-41行)。 - Candidateのsource_label/from_learningを廃止し、CandidateSource自体を source: Option<CandidateSource>として保持。ラベルと削除可否は読み出し 時に導出(source_label()/is_deletable())し、2つの派生値が食い違う 余地をなくす。enumはcore/candidate.rsへ移動 - 学習キャッシュの上限2種をLearningConfigにまとめ、new/loadの引数に。 set_max_surface_charsセッターを廃止し、設定値の適用漏れを型で防ぐ - 削除方式の作り直し化で不要になったCandidateList::remove_selectedと LearningCache::remove(exact一致版)を削除 - CLAUDE.md/READMEの履歴削除の記述を現仕様(作り直し・prefix双子削除・ 非学習候補はDoNothing・サジェスト窓対応)に同期 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: 履歴削除のトリガーを変換中の候補選択時のみに限定 入力中(Composing)の Ctrl+Backspace によるサジェスト先頭の学習候補 削除を撤回し、従来どおりの文字削除に戻す。削除ヒントが表示されず 押す動機のない場面で、文字を消す意図のキーが不可逆な履歴削除に 化けるのを避ける。履歴削除は変換候補リストで学習候補を選択して Ctrl+Backspace の一本道のみとする。 あわせて README / default.toml の学習まわりの記述をユーザー視点に 整理(内部用語・実装挙動の説明を削除)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1c27613 commit 7747e2a

13 files changed

Lines changed: 803 additions & 135 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ The engine-internal `InputMode::Alphabet` (entered via Shift+letter on Linux/fci
188188
- Models use jinen format with special Unicode tokens (U+EE00–U+EE02) from the Private Use Area; model input is katakana (hiragana is converted to katakana before inference)
189189
- Model registry defined in `karukan-engine/models.toml`; default models use Q5_K_M quantization
190190
- Live conversion (auto-suggest) splits the composing buffer into internal chunks of at most `composing_chunk_len` reading chars (default 40, configurable) so each model call stays bounded for long input. Chunking (`group_chunks`) starts a new chunk whenever the current one is full OR the character group changes between Japanese and non-Japanese (`is_japanese`: hiragana, katakana incl. `ー`, and kanji are Japanese; ASCII/full-width digits, letters, symbols, and all punctuation are non-Japanese). A non-Japanese run (digits/symbols/alphabet) is passed through to the preedit verbatim and never sent to the neural converter (which otherwise tends to drop digits mid-run, e.g. `123456`); a Japanese run is converted by the model. Because punctuation is non-Japanese, it forms its own chunk and naturally separates clauses (`今日は。明日` → `今日は`/`。`/`明日`), so there is no separate punctuation rule. A katakana word like `スーパーマーケット` is entirely Japanese, so it stays one chunk. `chunked_auto_suggest` re-chunks incrementally: it diffs the new buffer against the previous chunking by common character prefix/suffix and reconverts only the changed span (`ChunkPlan` decides which leading/trailing chunks to reuse). Each chunk's left context (lctx) is the editor surrounding text plus the converted text of the preceding chunks, truncated to `max_context_length`. Chunks are internal — the user sees one continuous preedit, and the aux text shows the current chunk's lctx as its single `lctx:`
191-
- Learning cache records user-selected conversions and boosts them on subsequent conversions; candidate priority: Learning → User Dictionary → Model → System Dictionary → Fallback → Rewriter
191+
- Learning cache records user-selected conversions and boosts them on subsequent conversions; candidate priority: Learning → User Dictionary → Model → System Dictionary → Fallback → Rewriter. Surfaces longer than `max_surface_chars` (default 50, `[learning]` in config.toml; both learning limits travel as `LearningConfig`) are not recorded. During conversion, Ctrl+Backspace or Ctrl+Delete (the Mac "delete" key is Backspace) removes the selected learning candidate from the history, mozc-style (`DeleteSelectedCandidate`): the removal clears the entry's whole prefix fan-out (`LearningCache::remove_suggestion`) so deduped twins under longer readings can't resurface, and the conversion is then rebuilt in place (mozc's cancel-and-reconvert, minus the window blink) so a surface that the model/dictionary/fallback also produce survives as an ordinary candidate. With a non-learning candidate selected the chord is consumed but does nothing (mozc's `DoNothing`); cancelling stays on plain Backspace/Escape; the chord exists only in the Conversion state (Composing keeps plain char editing). Deletability is derived from `Candidate::source` (`CandidateSource::is_deletable`, mozc's `USER_HISTORY_PREDICTION` analogue), and while a learning candidate is selected the aux text shows the mozc-style footer hint 「Ctrl+Backspaceで履歴から削除」
192192
- Data files (system dictionary `dict.bin`, user dictionaries `user_dicts/`, learning cache `learning.tsv`) live in the data directory: `~/.local/share/karukan-im/` on Linux, `~/Library/Application Support/com.karukan.karukan-im/` on macOS; a prebuilt `dict.tgz` is published on GitHub releases
193193
- Learning cache is persisted as TSV (`learning.tsv` in the data directory); saved on deactivate and engine free, not on every commit
194194
- Learning score uses recency-weighted formula (mozc-inspired): `recency * 10.0 + ln(1 + frequency)`; eviction removes lowest-score entries when over `max_entries` (default: 10,000)

karukan-engine/src/learning.rs

Lines changed: 186 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,38 @@ pub struct LearningEntry {
2020
pub last_access: u64,
2121
}
2222

23+
/// Size limits for a [`LearningCache`].
24+
///
25+
/// Passed whole at construction ([`LearningCache::new`] /
26+
/// [`LearningCache::load`]) so a caller can't apply one limit and forget the
27+
/// other — there is no post-construction setter to miss.
28+
#[derive(Debug, Clone, Copy)]
29+
pub struct LearningConfig {
30+
/// Maximum number of total entries across all readings; lowest-score
31+
/// entries are evicted on save when over this limit.
32+
pub max_entries: usize,
33+
/// Maximum surface length (Unicode chars) that [`LearningCache::record`]
34+
/// accepts. Keeps whole-sentence live-conversion commits — one-off text
35+
/// that never matches again — out of the cache.
36+
pub max_surface_chars: usize,
37+
}
38+
39+
impl LearningConfig {
40+
/// Default for [`max_entries`](Self::max_entries).
41+
pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
42+
/// Default for [`max_surface_chars`](Self::max_surface_chars).
43+
pub const DEFAULT_MAX_SURFACE_CHARS: usize = 50;
44+
}
45+
46+
impl Default for LearningConfig {
47+
fn default() -> Self {
48+
Self {
49+
max_entries: Self::DEFAULT_MAX_ENTRIES,
50+
max_surface_chars: Self::DEFAULT_MAX_SURFACE_CHARS,
51+
}
52+
}
53+
}
54+
2355
/// In-memory cache of user learning data.
2456
///
2557
/// Keyed by reading (hiragana). Each reading maps to a list of surface
@@ -28,24 +60,29 @@ pub struct LearningEntry {
2860
pub struct LearningCache {
2961
entries: HashMap<String, Vec<LearningEntry>>,
3062
max_entries: usize,
63+
max_surface_chars: usize,
3164
dirty: bool,
3265
}
3366

3467
impl LearningCache {
35-
/// Default maximum number of total entries across all readings.
36-
pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
37-
38-
/// Create an empty cache with the given entry limit.
39-
pub fn new(max_entries: usize) -> Self {
68+
/// Create an empty cache with the given limits.
69+
pub fn new(config: LearningConfig) -> Self {
4070
Self {
4171
entries: HashMap::new(),
42-
max_entries,
72+
max_entries: config.max_entries,
73+
max_surface_chars: config.max_surface_chars,
4374
dirty: false,
4475
}
4576
}
4677

4778
/// Record a user selection. Increments frequency and updates last_access.
79+
///
80+
/// Surfaces longer than `max_surface_chars` are skipped; see
81+
/// [`LearningConfig::max_surface_chars`] for why.
4882
pub fn record(&mut self, reading: &str, surface: &str) {
83+
if surface.chars().count() > self.max_surface_chars {
84+
return;
85+
}
4986
let now = now_unix();
5087
let entries = self.entries.entry(reading.to_string()).or_default();
5188

@@ -62,6 +99,29 @@ impl LearningCache {
6299
self.dirty = true;
63100
}
64101

102+
/// Remove every learned entry that would resurface `surface` for input
103+
/// `reading`: the exact-reading entry plus every longer reading with
104+
/// `reading` as a prefix (the [`prefix_lookup`](Self::prefix_lookup)
105+
/// fan-out) — an exact-only delete would leave a twin that pops back on
106+
/// the next conversion. Returns whether anything was removed; persisted
107+
/// at the next `save`.
108+
pub fn remove_suggestion(&mut self, reading: &str, surface: &str) -> bool {
109+
let mut removed = false;
110+
self.entries.retain(|r, entries| {
111+
if !r.starts_with(reading) {
112+
return true;
113+
}
114+
let before = entries.len();
115+
entries.retain(|e| e.surface != surface);
116+
removed |= entries.len() != before;
117+
!entries.is_empty()
118+
});
119+
if removed {
120+
self.dirty = true;
121+
}
122+
removed
123+
}
124+
65125
/// Exact-match lookup: returns `(surface, score)` pairs sorted by score descending.
66126
pub fn lookup(&self, reading: &str) -> Vec<(String, f64)> {
67127
let now = now_unix();
@@ -96,10 +156,10 @@ impl LearningCache {
96156
///
97157
/// Format: `reading\tsurface\tfrequency\tlast_access`
98158
/// Lines starting with `#` are comments.
99-
pub fn load(path: &Path, max_entries: usize) -> anyhow::Result<Self> {
159+
pub fn load(path: &Path, config: LearningConfig) -> anyhow::Result<Self> {
100160
let file = std::fs::File::open(path)?;
101161
let reader = std::io::BufReader::new(file);
102-
let mut cache = Self::new(max_entries);
162+
let mut cache = Self::new(config);
103163

104164
for line in reader.lines() {
105165
let line = line?;
@@ -250,11 +310,23 @@ fn now_unix() -> u64 {
250310
#[cfg(test)]
251311
mod tests {
252312
use super::*;
313+
314+
/// Config/cache with a custom entry limit and the default surface cap.
315+
fn config_with(max_entries: usize) -> LearningConfig {
316+
LearningConfig {
317+
max_entries,
318+
..LearningConfig::default()
319+
}
320+
}
321+
322+
fn cache_with(max_entries: usize) -> LearningCache {
323+
LearningCache::new(config_with(max_entries))
324+
}
253325
use tempfile::NamedTempFile;
254326

255327
#[test]
256328
fn test_record_and_lookup() {
257-
let mut cache = LearningCache::new(100);
329+
let mut cache = cache_with(100);
258330

259331
cache.record("きょう", "今日");
260332
cache.record("きょう", "京");
@@ -269,14 +341,14 @@ mod tests {
269341

270342
#[test]
271343
fn test_lookup_empty() {
272-
let cache = LearningCache::new(100);
344+
let cache = cache_with(100);
273345
let results = cache.lookup("きょう");
274346
assert!(results.is_empty());
275347
}
276348

277349
#[test]
278350
fn test_prefix_lookup() {
279-
let mut cache = LearningCache::new(100);
351+
let mut cache = cache_with(100);
280352
cache.record("きょう", "今日");
281353
cache.record("きょうと", "京都");
282354
cache.record("あした", "明日");
@@ -291,15 +363,15 @@ mod tests {
291363

292364
#[test]
293365
fn test_prefix_lookup_no_match() {
294-
let mut cache = LearningCache::new(100);
366+
let mut cache = cache_with(100);
295367
cache.record("きょう", "今日");
296368
let results = cache.prefix_lookup("あ");
297369
assert!(results.is_empty());
298370
}
299371

300372
#[test]
301373
fn test_save_and_load() {
302-
let mut cache = LearningCache::new(100);
374+
let mut cache = cache_with(100);
303375
cache.record("きょう", "今日");
304376
cache.record("きょう", "今日");
305377
cache.record("きょう", "京");
@@ -311,7 +383,7 @@ mod tests {
311383
cache.save(&path).unwrap();
312384
assert!(!cache.is_dirty());
313385

314-
let loaded = LearningCache::load(&path, 100).unwrap();
386+
let loaded = LearningCache::load(&path, config_with(100)).unwrap();
315387
assert!(!loaded.is_dirty());
316388
assert_eq!(loaded.entry_count(), 3);
317389

@@ -322,7 +394,7 @@ mod tests {
322394

323395
#[test]
324396
fn test_dirty_flag() {
325-
let mut cache = LearningCache::new(100);
397+
let mut cache = cache_with(100);
326398
assert!(!cache.is_dirty());
327399

328400
cache.record("きょう", "今日");
@@ -335,7 +407,7 @@ mod tests {
335407

336408
#[test]
337409
fn test_eviction() {
338-
let mut cache = LearningCache::new(3);
410+
let mut cache = cache_with(3);
339411

340412
// Add 5 entries
341413
cache.record("a", "A");
@@ -390,13 +462,13 @@ mod tests {
390462

391463
#[test]
392464
fn test_load_nonexistent_file() {
393-
let result = LearningCache::load(Path::new("/nonexistent/path"), 100);
465+
let result = LearningCache::load(Path::new("/nonexistent/path"), config_with(100));
394466
assert!(result.is_err());
395467
}
396468

397469
#[test]
398470
fn test_tsv_format() {
399-
let mut cache = LearningCache::new(100);
471+
let mut cache = cache_with(100);
400472
cache.record("きょう", "今日");
401473

402474
let file = NamedTempFile::new().unwrap();
@@ -416,13 +488,107 @@ mod tests {
416488
)
417489
.unwrap();
418490

419-
let cache = LearningCache::load(file.path(), 100).unwrap();
491+
let cache = LearningCache::load(file.path(), config_with(100)).unwrap();
420492
assert_eq!(cache.entry_count(), 1);
421493
let results = cache.lookup("きょう");
422494
assert_eq!(results.len(), 1);
423495
assert_eq!(results[0].0, "今日");
424496
}
425497

498+
#[test]
499+
fn test_remove_suggestion_last_surface_drops_reading() {
500+
let mut cache = cache_with(100);
501+
cache.record("きょう", "今日");
502+
503+
assert!(cache.remove_suggestion("きょう", "今日"));
504+
assert_eq!(cache.entry_count(), 0);
505+
assert!(cache.lookup("きょう").is_empty());
506+
assert!(cache.prefix_lookup("き").is_empty());
507+
}
508+
509+
#[test]
510+
fn test_remove_suggestion_clears_exact_and_prefix_twins() {
511+
let mut cache = cache_with(100);
512+
cache.record("あい", "藍");
513+
cache.record("あいさ", "藍"); // same surface, longer reading (a twin)
514+
cache.record("あい", "愛"); // different surface under the same reading
515+
cache.record("うみ", "藍"); // same surface, unrelated reading
516+
517+
let file = NamedTempFile::new().unwrap();
518+
cache.save(file.path()).unwrap();
519+
assert!(!cache.is_dirty());
520+
521+
assert!(cache.remove_suggestion("あい", "藍"));
522+
assert!(cache.is_dirty(), "removal must mark the cache dirty");
523+
524+
// Both the exact entry and the prefix twin are gone...
525+
assert!(cache.lookup("あい").iter().all(|(s, _)| s != "藍"));
526+
assert!(cache.lookup("あいさ").is_empty());
527+
// ...but a different surface under the same reading survives...
528+
assert!(cache.lookup("あい").iter().any(|(s, _)| s == "愛"));
529+
// ...and the same surface under an unrelated reading is untouched.
530+
assert!(cache.lookup("うみ").iter().any(|(s, _)| s == "藍"));
531+
}
532+
533+
#[test]
534+
fn test_remove_suggestion_nonexistent_is_noop() {
535+
let mut cache = cache_with(100);
536+
cache.record("あい", "藍");
537+
538+
let file = NamedTempFile::new().unwrap();
539+
cache.save(file.path()).unwrap();
540+
541+
assert!(!cache.remove_suggestion("あい", "愛"));
542+
assert!(!cache.remove_suggestion("かき", "柿"));
543+
assert!(!cache.is_dirty(), "no-op removal must not mark dirty");
544+
}
545+
546+
#[test]
547+
fn test_record_skips_long_surface() {
548+
let mut cache = LearningCache::new(LearningConfig {
549+
max_entries: 100,
550+
max_surface_chars: 5,
551+
});
552+
553+
cache.record("あ", &"漢".repeat(6));
554+
assert_eq!(cache.entry_count(), 0);
555+
assert!(!cache.is_dirty());
556+
557+
// Boundary: exactly max_surface_chars is accepted.
558+
cache.record("あ", &"漢".repeat(5));
559+
assert_eq!(cache.entry_count(), 1);
560+
}
561+
562+
#[test]
563+
fn test_record_ignores_reading_length() {
564+
let mut cache = LearningCache::new(LearningConfig {
565+
max_entries: 100,
566+
max_surface_chars: 5,
567+
});
568+
569+
// Only the surface is capped; a long reading with a short surface
570+
// is fine (e.g. a long kana reading converting to a short word).
571+
cache.record(&"あ".repeat(30), "短い");
572+
assert_eq!(cache.entry_count(), 1);
573+
}
574+
575+
#[test]
576+
fn test_default_max_surface_chars() {
577+
let mut cache = cache_with(100);
578+
579+
cache.record(
580+
"よみ",
581+
&"あ".repeat(LearningConfig::DEFAULT_MAX_SURFACE_CHARS + 1),
582+
);
583+
assert_eq!(cache.entry_count(), 0);
584+
585+
cache.record(
586+
"よみ",
587+
&"あ".repeat(LearningConfig::DEFAULT_MAX_SURFACE_CHARS),
588+
);
589+
assert_eq!(cache.entry_count(), 1);
590+
}
591+
426592
#[test]
427593
fn test_tsv_malformed_lines_skipped() {
428594
let file = NamedTempFile::new().unwrap();
@@ -432,7 +598,7 @@ mod tests {
432598
)
433599
.unwrap();
434600

435-
let cache = LearningCache::load(file.path(), 100).unwrap();
601+
let cache = LearningCache::load(file.path(), config_with(100)).unwrap();
436602
// Only the first valid line should be loaded
437603
assert_eq!(cache.entry_count(), 1);
438604
}

karukan-engine/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use kana::{
1111
katakana_to_hiragana, normalize_nfkc,
1212
};
1313
pub use kanji::{Backend, KanaKanjiConverter};
14-
pub use learning::LearningCache;
14+
pub use learning::{LearningCache, LearningConfig};
1515
pub use rewriter::{
1616
AlphabetRewriter, EmojiRewriter, HalfWidthKatakanaRewriter, RewriteOutput, Rewriter,
1717
RewriterChain, SymbolRewriter, description as symbol_description,

karukan-im/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ dict_path = "/path/to/dict.bin" # システム辞書パス(省略時はデー
8383
[learning]
8484
enabled = true # 変換学習の有効/無効
8585
max_entries = 10000 # 学習エントリの最大数
86+
max_surface_chars = 50 # 学習する変換結果の最大文字数
8687
```
8788

8889
> [!NOTE]
@@ -171,6 +172,8 @@ cp dict.bin ~/Library/"Application Support"/com.karukan.karukan-im/
171172
- 例: 「早稲田大学」を一度変換すると、次回「わせだ」と入力した時点で候補に表示
172173
- 学習候補は変換時・入力中(auto-suggest)の両方で最大3件表示
173174
- スコアはrecency(最終使用日時)重視 + 頻度補正
175+
- 50文字(`max_surface_chars`)を超える変換結果は学習しない
176+
- 変換中に学習候補(📝)を選択して `Ctrl+Backspace`(macOSでは Ctrl+delete。`Ctrl+Delete` でも可)を押すと、そのエントリを学習履歴から削除できる。学習候補の選択中はフッターに「Ctrl+Backspaceで履歴から削除」と表示される
174177
- IME切り替え・ウィンドウ切り替え時に自動保存(commit のたびには保存しない)
175178
- `[learning] enabled = false` で無効化可能
176-
- 学習履歴を削除するには: `rm ~/.local/share/karukan-im/learning.tsv`
179+
- 学習履歴をすべて削除するには: `rm ~/.local/share/karukan-im/learning.tsv`

karukan-im/config/default.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ live_conversion = true
3636
enabled = true
3737
# 学習エントリの最大数
3838
max_entries = 10000
39+
# 学習する変換結果の最大文字数
40+
max_surface_chars = 50

0 commit comments

Comments
 (0)