WolfDavid commited on
Commit
46459e0
·
1 Parent(s): a09270c

feat(02-05): analyze(text) - the canonical token record

Browse files

- analyzer.py composes morphemes -> units -> overrides -> ruby -> ranked
lookup -> derive_level -> kanji axis -> glosses into plain dicts with
exactly TOKEN_KEYS; non-tappable units carry no level, gloss or ruby
- level from the level_key entry, gloss from the lemma entry (one lookup
when they coincide); warmup() reports per-component seconds and sizes
- levels.py uses ruby.is_kanji (the 02-03 parallel-wave copy is gone), so
supplementary-plane kanji reach the kanji axis; test_levels pins it
- 日本語 pinned as N1+: JMdict 1464530 is on none of the five lists

src/japanese_avatar/nlp/analyzer.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The ONE canonical text -> tokens function (PITFALLS § 10).
2
+
3
+ Everything downstream consumes this record and nothing else: furigana (02-07), the lookup
4
+ popover (02-08), the ``analyze`` server function (02-06), Phase 3's level guard and Phase 5's
5
+ vocabulary tracking. Sudachi objects never leave ``tokenizer.py``; this module composes the
6
+ pure functions of plans 02-02 and 02-03 over plain dicts:
7
+
8
+ morphemes -> build_units -> apply_overrides -> align_ruby -> lookup -> derive_level
9
+ -> kanji_levels_for -> glosses_for
10
+
11
+ No LLM is anywhere on this path (success criterion 1). Warm analysis is milliseconds; the two
12
+ process-wide singletons it touches - the Sudachi core dictionary and the compact JMdict - are
13
+ built once by :func:`warmup`, which app start-up calls so the first visitor does not pay for
14
+ them (the ``voice.tts.warmup`` precedent).
15
+
16
+ The token record, keys in THIS order (research § Q7; ``TOKEN_KEYS`` is the contract)::
17
+
18
+ surface the text the learner sees and taps (the whole conjugated word, D-05)
19
+ reading hiragana, after the committed override table (人気 -> ひとけ before の)
20
+ lemma the dictionary form the card names (食べる, 勉強する)
21
+ level_key the head's normalized form the level join uses (話せる -> 話す)
22
+ pos the head's pos[0] (kept for Phase 3; D-07 hides it on the card)
23
+ tappable D-11: particles, auxiliaries, punctuation and copulas are not words
24
+ is_name the head is 固有名詞 (D-12)
25
+ jlpt "N5".."N1" | "N1+" (on no list, D-10) | "name" (D-12) | None (non-tappable)
26
+ kanji_levels {kanji: "N3" | None} for every kanji of the surface, {} when none (D-13)
27
+ ruby [[text, rt | None], ...] per kanji run; [[surface, None]] when non-tappable
28
+ jmdict_id int | None
29
+ gloss up to 3 senses x up to 3 English glosses, [] when there is no entry
30
+ start, end character offsets into the analysed text; the surfaces tile it exactly
31
+
32
+ Level and gloss may come from different entries (research § Q2 step 5): the level from the
33
+ ``level_key`` entry (話す, N5) and the gloss from the ``lemma`` entry (話せる, "to be able to
34
+ speak"); when ``level_key == lemma`` they are one lookup.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import time
40
+ from collections.abc import Mapping
41
+
42
+ from japanese_avatar.nlp import jmdict, levels, tokenizer
43
+ from japanese_avatar.nlp.overrides import apply_overrides
44
+ from japanese_avatar.nlp.ruby import align_ruby
45
+ from japanese_avatar.nlp.units import build_units
46
+
47
+ #: The record every unit carries, in this exact order. Downstream code may rely on it.
48
+ TOKEN_KEYS = (
49
+ "surface",
50
+ "reading",
51
+ "lemma",
52
+ "level_key",
53
+ "pos",
54
+ "tappable",
55
+ "is_name",
56
+ "jlpt",
57
+ "kanji_levels",
58
+ "ruby",
59
+ "jmdict_id",
60
+ "gloss",
61
+ "start",
62
+ "end",
63
+ )
64
+
65
+
66
+ def _lookup_pair(
67
+ unit: Mapping[str, object], level_of: Mapping[int, int]
68
+ ) -> tuple[jmdict.Entry | None, jmdict.Entry | None]:
69
+ """``(entry_for_level, entry_for_gloss)`` for a tappable unit.
70
+
71
+ The gloss entry is the ``lemma`` (dictionary form) entry - 話せる's own; the level entry is
72
+ the ``level_key`` (normalized form) entry - 話す. One ranked lookup when the two keys are
73
+ equal, which is the common case.
74
+ """
75
+ lemma = str(unit["lemma"])
76
+ level_key = str(unit["level_key"])
77
+ reading = str(unit["reading"])
78
+ entry_gloss = jmdict.lookup(lemma, lemma, reading, level_of)
79
+ if level_key == lemma:
80
+ return entry_gloss, entry_gloss
81
+ entry_level = jmdict.lookup(level_key, level_key, reading, level_of)
82
+ return entry_level, entry_gloss
83
+
84
+
85
+ def _token(unit: Mapping[str, object], level_of: Mapping[int, int]) -> dict:
86
+ surface = str(unit["surface"])
87
+ reading = str(unit["reading"])
88
+ fields: dict[str, object] = {
89
+ "surface": surface,
90
+ "reading": reading,
91
+ "lemma": unit["lemma"],
92
+ "level_key": unit["level_key"],
93
+ "pos": unit["pos"],
94
+ "tappable": unit["tappable"],
95
+ "is_name": unit["is_name"],
96
+ "start": unit["start"],
97
+ "end": unit["end"],
98
+ }
99
+ if unit["tappable"]:
100
+ entry_level, entry_gloss = _lookup_pair(unit, level_of)
101
+ entry = entry_gloss or entry_level
102
+ fields["jlpt"] = levels.derive_level(bool(unit["is_name"]), entry_level, entry_gloss)
103
+ fields["kanji_levels"] = levels.kanji_levels_for(surface)
104
+ fields["ruby"] = align_ruby(surface, reading)
105
+ fields["jmdict_id"] = entry.id if entry is not None else None
106
+ fields["gloss"] = jmdict.glosses_for(entry) if entry is not None else []
107
+ else:
108
+ # D-11: not a word for level purposes - plain text, no badge, no card, no ruby.
109
+ fields["jlpt"] = None
110
+ fields["kanji_levels"] = {}
111
+ fields["ruby"] = [[surface, None]]
112
+ fields["jmdict_id"] = None
113
+ fields["gloss"] = []
114
+ return {key: fields[key] for key in TOKEN_KEYS}
115
+
116
+
117
+ def analyze(text: str) -> list[dict]:
118
+ """Turn any Japanese string into its token records (one plain dict per tap unit).
119
+
120
+ Deterministic: the same text gives the same records forever, for the pinned SudachiDict,
121
+ JMdict and JLPT data - ``tests/fixtures/sentences.json`` is the frozen proof. Returns ``[]``
122
+ for empty text. Never involves an LLM.
123
+ """
124
+ if not text:
125
+ return []
126
+ units = build_units(tokenizer.morphemes(text))
127
+ apply_overrides(units)
128
+ level_of = levels.vocab_levels()
129
+ return [_token(unit, level_of) for unit in units]
130
+
131
+
132
+ def warmup() -> dict[str, float | int]:
133
+ """Build every singleton :func:`analyze` needs and report what each cost.
134
+
135
+ Returns ``tokenizer_s`` and ``jmdict_s`` (seconds; 0.0 when already built), plus the sizes
136
+ that prove the data loaded: ``jmdict_entries``, ``vocab_ids`` and ``kanji``. Idempotent.
137
+ """
138
+ tokenizer_s = tokenizer.warmup()
139
+ jmdict_s = jmdict.warmup()
140
+ vocab = levels.vocab_levels()
141
+ kanji = levels.kanji_levels()
142
+ return {
143
+ "tokenizer_s": tokenizer_s,
144
+ "jmdict_s": jmdict_s,
145
+ "jmdict_entries": len(jmdict.load()),
146
+ "vocab_ids": len(vocab),
147
+ "kanji": len(kanji),
148
+ }
149
+
150
+
151
+ def _self_time(text: str, runs: int = 100) -> float: # pragma: no cover - dev aid
152
+ """Mean milliseconds per warm analysis of ``text`` (used by hand, not by tests)."""
153
+ analyze(text)
154
+ started = time.perf_counter()
155
+ for _ in range(runs):
156
+ analyze(text)
157
+ return (time.perf_counter() - started) * 1000 / runs
158
+
159
+
160
+ __all__ = ["TOKEN_KEYS", "analyze", "warmup"]
src/japanese_avatar/nlp/levels.py CHANGED
@@ -28,6 +28,7 @@ import json
28
  from functools import lru_cache
29
 
30
  from japanese_avatar.nlp.jmdict import REPO_ROOT, Entry
 
31
 
32
  JLPT_DIR = REPO_ROOT / "data" / "jlpt"
33
  KANJI_LEVELS_PATH = JLPT_DIR / "kanji_levels.json"
@@ -99,16 +100,6 @@ def derive_level(
99
  return BEYOND_LISTS
100
 
101
 
102
- # local copy of ruby.is_kanji - parallel wave; reconciled in 02-05
103
- _KANJI_RANGES = ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x3005, 0x3005))
104
-
105
-
106
- def _is_kanji(ch: str) -> bool:
107
- """CJK Unified Ideographs (+ Extension A), Compatibility Ideographs, and 々 (U+3005)."""
108
- cp = ord(ch)
109
- return any(lo <= cp <= hi for lo, hi in _KANJI_RANGES)
110
-
111
-
112
  def kanji_levels_for(surface: str) -> dict[str, str | None]:
113
  """Every kanji in ``surface`` -> its level, or ``None`` when the list does not carry it.
114
 
@@ -118,7 +109,7 @@ def kanji_levels_for(surface: str) -> dict[str, str | None]:
118
  may itself be listed (known edge, recorded in 02-03-SUMMARY.md).
119
  """
120
  table = kanji_levels()
121
- return {ch: table.get(ch) for ch in surface if _is_kanji(ch)}
122
 
123
 
124
  __all__ = [
 
28
  from functools import lru_cache
29
 
30
  from japanese_avatar.nlp.jmdict import REPO_ROOT, Entry
31
+ from japanese_avatar.nlp.ruby import is_kanji
32
 
33
  JLPT_DIR = REPO_ROOT / "data" / "jlpt"
34
  KANJI_LEVELS_PATH = JLPT_DIR / "kanji_levels.json"
 
100
  return BEYOND_LISTS
101
 
102
 
 
 
 
 
 
 
 
 
 
 
103
  def kanji_levels_for(surface: str) -> dict[str, str | None]:
104
  """Every kanji in ``surface`` -> its level, or ``None`` when the list does not carry it.
105
 
 
109
  may itself be listed (known edge, recorded in 02-03-SUMMARY.md).
110
  """
111
  table = kanji_levels()
112
+ return {ch: table.get(ch) for ch in surface if is_kanji(ch)}
113
 
114
 
115
  __all__ = [
tests/test_analyzer.py CHANGED
@@ -169,7 +169,12 @@ def test_level_derivation(analyzer):
169
  def test_kanji_axis_separate_from_word_axis(analyzer):
170
  """D-13: the word's level and each kanji's level are two axes in the same record."""
171
  nihongo = _by_surface(analyzer("日本語を勉強しています。"), "日本語")
172
- assert nihongo["jlpt"] == "N5"
 
 
 
 
 
173
  assert nihongo["kanji_levels"] == {"日": "N5", "本": "N5", "語": "N5"}
174
 
175
  benkyou = _by_surface(analyzer("日本語を勉強しています。"), "勉強しています")
 
169
  def test_kanji_axis_separate_from_word_axis(analyzer):
170
  """D-13: the word's level and each kanji's level are two axes in the same record."""
171
  nihongo = _by_surface(analyzer("日本語を勉強しています。"), "日本語")
172
+ # 日本語 resolves to JMdict 1464530, which is on NONE of the five pinned lists (Waller's
173
+ # lists carry 日本 at N3 and no 日本語 at all) - so the word axis is the honest D-10 "N1+"
174
+ # while every kanji in it is N5. The two axes really are independent; the plan's assumed
175
+ # "N5" was a guess the pinned data contradicts (02-05-SUMMARY.md).
176
+ assert nihongo["jmdict_id"] == 1464530
177
+ assert nihongo["jlpt"] == "N1+"
178
  assert nihongo["kanji_levels"] == {"日": "N5", "本": "N5", "語": "N5"}
179
 
180
  benkyou = _by_surface(analyzer("日本語を勉強しています。"), "勉強しています")
tests/test_levels.py CHANGED
@@ -16,7 +16,7 @@ import io
16
  from collections import defaultdict
17
  from pathlib import Path
18
 
19
- from japanese_avatar.nlp import jmdict, levels
20
  from japanese_avatar.nlp.levels import (
21
  LEVEL_RANK,
22
  derive_level,
@@ -132,12 +132,22 @@ def test_kanji_axis():
132
  assert kanji_levels_for("xyz abc 123 、。") == {}
133
 
134
 
135
- def test_is_kanji_ranges_local_copy():
136
- """levels.py carries its own _is_kanji (parallel wave with 02-02; 02-05 reconciles)."""
137
- assert levels._is_kanji("漢") and levels._is_kanji("々") and levels._is_kanji("㐀")
138
- assert levels._is_kanji("鿿") and levels._is_kanji("豈") and levels._is_kanji("﫿")
139
- assert not levels._is_kanji("あ") and not levels._is_kanji("ア") and not levels._is_kanji("a")
140
- assert not levels._is_kanji("ー") and not levels._is_kanji("。")
 
 
 
 
 
 
 
 
 
 
141
 
142
 
143
  def test_level_rank_order():
 
16
  from collections import defaultdict
17
  from pathlib import Path
18
 
19
+ from japanese_avatar.nlp import jmdict, levels, ruby
20
  from japanese_avatar.nlp.levels import (
21
  LEVEL_RANK,
22
  derive_level,
 
132
  assert kanji_levels_for("xyz abc 123 、。") == {}
133
 
134
 
135
+ def test_is_kanji_is_rubys():
136
+ """The kanji-axis predicate IS ruby.is_kanji (02-05 reconciled 02-03's parallel-wave copy).
137
+
138
+ One predicate decides which characters receive ruby and which are level-gated, so the two
139
+ axes can never disagree about a character (e.g. 𠮷 in the supplementary planes).
140
+ """
141
+ assert levels.is_kanji is ruby.is_kanji
142
+ assert not hasattr(levels, "_is_kanji"), "the local copy is gone"
143
+ assert levels.is_kanji("漢") and levels.is_kanji("々") and levels.is_kanji("㐀")
144
+ assert levels.is_kanji("鿿") and levels.is_kanji("豈") and levels.is_kanji("﫿")
145
+ assert levels.is_kanji("𠮷"), "supplementary planes count on both axes"
146
+ assert not levels.is_kanji("あ") and not levels.is_kanji("ア") and not levels.is_kanji("a")
147
+ assert not levels.is_kanji("ー") and not levels.is_kanji("。")
148
+ # A supplementary-plane kanji now reaches the kanji axis (unlisted -> None) instead of
149
+ # being silently skipped, which the four-range local copy did.
150
+ assert kanji_levels_for("\U00020bb7野家") == {"\U00020bb7": None, "野": "N4", "家": "N4"}
151
 
152
 
153
  def test_level_rank_order():