WolfDavid commited on
Commit
6609f52
·
1 Parent(s): e39dc2e

feat(02-02): mode-C morphemes to whole-word units (rules A1-A7)

Browse files

- tokenizer.py: lazy lru_cache Sudachi core singleton, warmup(), plain-dict morphemes()
- units.py: build_units() implementing the A0 guard and A1-A7 over dicts, no Sudachi import
- nlp/__init__.py: extend the package docstring with the boundary discipline

src/japanese_avatar/nlp/__init__.py CHANGED
@@ -1 +1,8 @@
1
- """Japanese language core: tokenizer, dictionary, JLPT levels, translation (Phase 2)."""
 
 
 
 
 
 
 
 
1
+ """Japanese language core: tokenizer, dictionary, JLPT levels, translation (Phase 2).
2
+
3
+ One canonical ``analyze(text)`` and the pure functions under it. Only ``tokenizer`` touches
4
+ SudachiPy, and only inside its functions, so ``import japanese_avatar.nlp`` constructs nothing and
5
+ stays importable where the wheel is absent. ``units``, ``ruby`` and ``overrides`` are pure
6
+ functions over plain dicts and strings - the ``voice.visemes`` discipline: no engine object
7
+ crosses a function boundary.
8
+ """
src/japanese_avatar/nlp/tokenizer.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SudachiPy tokenizer singleton and the plain-dict morpheme boundary.
2
+
3
+ Same shape as ``voice.tts``: nothing is built at import time, ``sudachipy`` is imported inside
4
+ the functions, and the dictionary (``sudachidict_core``, memory-mapped, ~0.1-0.2 s to open) is
5
+ created once by :func:`get_tokenizer` and shared read-only across sessions. :func:`warmup` lets
6
+ app startup pay that cost before the first visitor does.
7
+
8
+ :func:`morphemes` is the only place a Sudachi object is touched. It returns plain dicts so that
9
+ ``units.build_units`` and everything downstream is testable without the engine and no Sudachi
10
+ type ever crosses a function boundary.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import functools
16
+ import time
17
+ from typing import Any
18
+
19
+ #: Sudachi's longest split. D-05's tap unit is a whole word, and mode C still emits 食べ|まし|た,
20
+ #: which ``units.build_units`` joins back together.
21
+ _SPLIT_MODE = "C"
22
+
23
+
24
+ @functools.lru_cache(maxsize=1)
25
+ def get_tokenizer() -> Any:
26
+ """The process-wide Sudachi tokenizer over the core dictionary. Read-only; safe to share."""
27
+ from sudachipy import Dictionary
28
+
29
+ return Dictionary(dict="core").create()
30
+
31
+
32
+ def warmup() -> float:
33
+ """Build the tokenizer now and return the seconds it took (0.0 once it is already built)."""
34
+ t0 = time.perf_counter()
35
+ get_tokenizer()
36
+ return time.perf_counter() - t0
37
+
38
+
39
+ def morphemes(text: str) -> list[dict]:
40
+ """Mode-C morphemes of ``text`` as plain dicts, one per Sudachi morpheme, in text order.
41
+
42
+ Keys: ``surface``, ``reading`` (``reading_form()``, KATAKANA; punctuation returns itself),
43
+ ``lemma`` (``dictionary_form()``), ``norm`` (``normalized_form()``: 話せる -> 話す,
44
+ いい -> 良い), ``pos`` (the 6-tuple ``part_of_speech()`` as a list of str), ``oov``
45
+ (``is_oov()``).
46
+ """
47
+ if not text:
48
+ return []
49
+ from sudachipy import SplitMode
50
+
51
+ mode = getattr(SplitMode, _SPLIT_MODE)
52
+ return [
53
+ {
54
+ "surface": m.surface(),
55
+ "reading": m.reading_form(),
56
+ "lemma": m.dictionary_form(),
57
+ "norm": m.normalized_form(),
58
+ "pos": list(m.part_of_speech()),
59
+ "oov": bool(m.is_oov()),
60
+ }
61
+ for m in get_tokenizer().tokenize(text, mode)
62
+ ]
src/japanese_avatar/nlp/units.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mode-C morphemes -> whole-word tap units (D-05). Pure function over plain dicts, no Sudachi.
2
+
3
+ Sudachi's longest split still emits 食べ|まし|た; the learner perceives 食べました. This module
4
+ walks the morphemes ``tokenizer.morphemes`` returned and joins inflectional material onto its
5
+ stem with a small, deterministic, POS-driven rule set. A unit has a HEAD (its content morpheme,
6
+ which supplies lemma / level_key / pos / tappable) and a TAIL (its last morpheme, which the
7
+ attach rules inspect).
8
+
9
+ The joining rule (02-02-PLAN.md, the binding form of 02-RESEARCH.md § Q1). Walk left to right;
10
+ a new unit starts at every morpheme UNLESS one of these attaches it to the previous unit. A0 is
11
+ a guard evaluated BEFORE A1-A7:
12
+
13
+ A0 The PREVIOUS UNIT is a breaker - head pos[0] in NON_TAPPABLE_POS0, head lemma だ/です, or
14
+ head pos[0:2] == 形状詞,助動詞語幹 - so NO attach rule fires: breakers accept nothing.
15
+ きれい|じゃ|なかった, 人気|の|ない, 話せる|よう|に, 行かなけれ|ば|ならない.
16
+ A1 pos[0] == 助動詞, lemma not だ/です, tail pos[0] in {動詞, 形容詞, 助動詞}.
17
+ ます, た, たい, ない(助動詞), させる, られる, れる, ぬ, う/よう, まい.
18
+ A2 pos[0:2] == 形容詞,非自立可能 and norm == 無い and (tail is 助動詞, or tail is
19
+ 動詞/形容詞 in 未然形/連用形). 食べたく|なかっ|た, 美味しく|ない - the 形容詞 tail
20
+ is REQUIRED (research's prose omitted it).
21
+ A3 pos[0:2] == 助詞,接続助詞 and surface in {て, で} and tail pos[0] in
22
+ {動詞, 形容詞, 助動詞}. 食べ|て, 読ん|で; NOT ば / ながら / から.
23
+ A4 pos[0:2] == 動詞,非自立可能 and the tail is an A3-attached て/で.
24
+ し|て|いる, 読んで|ください, 見て|みる.
25
+ A5 pos[0:2] == 動詞,非自立可能 and lemma == する and the HEAD is 名詞 with
26
+ pos[2] == サ変可能. 勉強|し|ています -> lemma 勉強する.
27
+ A6 pos[0] == 接尾辞. 田中|さん, 三|人 (the head keeps lemma / level_key).
28
+ A7 The previous morpheme is 接頭辞: the PREFIX joins the NEXT unit (attach forward).
29
+ お|願い, ご|飯.
30
+
31
+ Contract: this table plus the expectations in ``tests/test_units.py`` ARE the joining contract.
32
+ Only Sudachi's MORPHEME output (surfaces, readings, POS tuples) is "observed and pinned" -
33
+ never a unit split. If the rule as tabulated ever yields a split that contradicts a test
34
+ expectation, the rule or its implementation is wrong and is fixed; the expectation stands.
35
+
36
+ Non-tappable units (D-11): head pos[0] in NON_TAPPABLE_POS0, or head 形状詞,助動詞語幹
37
+ (よう / そう / みたい), or head lemma だ/です. Everything else whose head pos[0] is in
38
+ TAPPABLE_POS is tappable, including an OOV numeral (三, norm "3"). Proper nouns are tappable
39
+ with ``is_name`` set (D-12).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import jaconv
45
+
46
+ #: Head POS that make a unit a tappable word (D-05 / D-11).
47
+ TAPPABLE_POS = frozenset(
48
+ {"名詞", "動詞", "形容詞", "形状詞", "副詞", "代名詞", "連体詞", "接続詞", "感動詞"}
49
+ )
50
+
51
+ #: Head POS of a plain-text unit (D-11). Such a unit is also a chain breaker (A0).
52
+ NON_TAPPABLE_POS0 = frozenset({"助詞", "助動詞", "補助記号", "空白", "記号"})
53
+
54
+ #: Copula lemmas: never attach (A1), never tappable, and break the chain (A0).
55
+ CHAIN_BREAKER_LEMMAS = frozenset({"だ", "です"})
56
+
57
+ #: The 形状詞 auxiliary stems (よう / そう / みたい): plain text and a chain breaker.
58
+ _AUX_STEM = ["形状詞", "助動詞語幹"]
59
+
60
+ #: Tails that inflectional material (A1, A3) may follow.
61
+ _INFLECTING = frozenset({"動詞", "形容詞", "助動詞"})
62
+
63
+ _TE_DE = frozenset({"て", "で"})
64
+
65
+ _A2_TAIL_FORMS = ("未然形", "連用形")
66
+
67
+
68
+ def is_breaker(head: dict) -> bool:
69
+ """A0 / D-11: a unit with this head accepts no attachment and is not tappable."""
70
+ pos = head["pos"]
71
+ return (
72
+ pos[0] in NON_TAPPABLE_POS0 or head["lemma"] in CHAIN_BREAKER_LEMMAS or pos[:2] == _AUX_STEM
73
+ )
74
+
75
+
76
+ class _Unit:
77
+ """Builder for one unit. Internal; ``build_units`` emits plain dicts."""
78
+
79
+ __slots__ = ("head_index", "morphs", "start", "suru", "tail_is_te")
80
+
81
+ def __init__(self, start: int) -> None:
82
+ self.morphs: list[dict] = []
83
+ self.head_index: int | None = None
84
+ self.start = start
85
+ self.tail_is_te = False # the tail is an A3-attached て/で (enables A4)
86
+ self.suru = False # A5 fired: lemma gains する
87
+
88
+ @property
89
+ def head(self) -> dict:
90
+ idx = 0 if self.head_index is None else self.head_index
91
+ return self.morphs[idx]
92
+
93
+ @property
94
+ def tail(self) -> dict:
95
+ return self.morphs[-1]
96
+
97
+ @property
98
+ def pending_prefix(self) -> bool:
99
+ return self.head_index is None
100
+
101
+ def add(self, m: dict, *, as_head: bool = False, rule: str | None = None) -> None:
102
+ if as_head:
103
+ self.head_index = len(self.morphs)
104
+ self.morphs.append(m)
105
+ self.tail_is_te = rule == "A3"
106
+ if rule == "A5":
107
+ self.suru = True
108
+
109
+ def to_dict(self) -> dict:
110
+ head = self.head
111
+ surface = "".join(m["surface"] for m in self.morphs)
112
+ reading = jaconv.kata2hira("".join(m["reading"] for m in self.morphs))
113
+ lemma = head["lemma"] + ("する" if self.suru else "")
114
+ return {
115
+ "surface": surface,
116
+ "reading": reading,
117
+ "lemma": lemma,
118
+ "level_key": head["norm"],
119
+ "pos": head["pos"][0],
120
+ "tappable": head["pos"][0] in TAPPABLE_POS and not is_breaker(head),
121
+ "is_name": head["pos"][1] == "固有名詞",
122
+ "start": self.start,
123
+ "end": self.start + len(surface),
124
+ "morpheme_count": len(self.morphs),
125
+ }
126
+
127
+
128
+ def attach_rule(m: dict, unit: _Unit) -> str | None:
129
+ """The rule (``"A1"``..``"A6"``) that attaches morpheme ``m`` to ``unit``, or ``None``.
130
+
131
+ A7 (prefix attaches forward) is handled by the walk in :func:`build_units`, because it is the
132
+ only rule that looks at the NEXT morpheme rather than the previous unit.
133
+ """
134
+ head, tail = unit.head, unit.tail
135
+ if is_breaker(head): # A0: breakers accept nothing
136
+ return None
137
+ pos = m["pos"]
138
+ tail_pos = tail["pos"]
139
+
140
+ if pos[0] == "助動詞" and m["lemma"] not in CHAIN_BREAKER_LEMMAS and tail_pos[0] in _INFLECTING:
141
+ return "A1"
142
+
143
+ if (
144
+ pos[:2] == ["形容詞", "非自立可能"]
145
+ and m["norm"] == "無い"
146
+ and (
147
+ tail_pos[0] == "助動詞"
148
+ or (tail_pos[0] in {"動詞", "形容詞"} and tail_pos[5].startswith(_A2_TAIL_FORMS))
149
+ )
150
+ ):
151
+ return "A2"
152
+
153
+ if pos[:2] == ["助詞", "接続助詞"] and m["surface"] in _TE_DE and tail_pos[0] in _INFLECTING:
154
+ return "A3"
155
+
156
+ if pos[:2] == ["動詞", "非自立可能"]:
157
+ if unit.tail_is_te:
158
+ return "A4"
159
+ if m["lemma"] == "する" and head["pos"][0] == "名詞" and head["pos"][2] == "サ変可能":
160
+ return "A5"
161
+
162
+ if pos[0] == "接尾辞":
163
+ return "A6"
164
+
165
+ return None
166
+
167
+
168
+ def build_units(morphs: list[dict]) -> list[dict]:
169
+ """Join mode-C morphemes into whole-word units by rules A0-A7. Input is not mutated.
170
+
171
+ Returns one plain dict per unit with keys ``surface``, ``reading`` (hiragana), ``lemma``,
172
+ ``level_key``, ``pos``, ``tappable``, ``is_name``, ``start``, ``end``, ``morpheme_count``;
173
+ ``start``/``end`` are character offsets into the text the morphemes came from, and the
174
+ surfaces tile it exactly.
175
+ """
176
+ units: list[_Unit] = []
177
+ offset = 0
178
+ for m in morphs:
179
+ if units and units[-1].pending_prefix:
180
+ # A7: the previous morpheme(s) were 接頭辞 - this morpheme becomes the head of that
181
+ # unit (a second prefix in a row keeps the unit pending).
182
+ units[-1].add(m, as_head=m["pos"][0] != "接頭辞")
183
+ elif m["pos"][0] == "接頭辞":
184
+ unit = _Unit(offset)
185
+ unit.add(m) # head pending until the next morpheme arrives
186
+ units.append(unit)
187
+ else:
188
+ rule = attach_rule(m, units[-1]) if units else None
189
+ if rule is None:
190
+ unit = _Unit(offset)
191
+ unit.add(m, as_head=True)
192
+ units.append(unit)
193
+ else:
194
+ units[-1].add(m, rule=rule)
195
+ offset += len(m["surface"])
196
+ return [u.to_dict() for u in units]