Spaces:
Running on Zero
Running on Zero
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 +160 -0
- src/japanese_avatar/nlp/levels.py +2 -11
- tests/test_analyzer.py +6 -1
- tests/test_levels.py +17 -7
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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 136 |
-
"""
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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():
|