sentence-struct 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """sentence-struct: multilingual sentence structure analysis."""
2
+
3
+ from sentence_struct.api import SUPPORTED_LANGUAGES, analyze, analyze_sentences
4
+ from sentence_struct.models import Chunk, Sentence, Token
5
+
6
+ __all__ = [
7
+ "SUPPORTED_LANGUAGES",
8
+ "Chunk",
9
+ "Sentence",
10
+ "Token",
11
+ "analyze",
12
+ "analyze_sentences",
13
+ ]
14
+
15
+ __version__ = "0.1.0"
sentence_struct/api.py ADDED
@@ -0,0 +1,44 @@
1
+ """Public analyze() entrypoint with language dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sentence_struct.models import Sentence
8
+ from sentence_struct.serialize import document_to_dict
9
+
10
+ SUPPORTED_LANGUAGES = frozenset({"ja"})
11
+
12
+
13
+ def analyze_sentences(text: str, *, language: str = "ja") -> list[Sentence]:
14
+ """Analyze text into Sentence objects (tokens + chunks per sentence)."""
15
+ lang = language.lower().strip()
16
+ if lang not in SUPPORTED_LANGUAGES:
17
+ supported = ", ".join(sorted(SUPPORTED_LANGUAGES))
18
+ raise ValueError(f"Unsupported language {language!r}. Supported: {supported}")
19
+
20
+ if lang == "ja":
21
+ from sentence_struct.backends.ja.segment import analyze as analyze_ja
22
+
23
+ return analyze_ja(text)
24
+
25
+ raise ValueError(f"Unsupported language {language!r}")
26
+
27
+
28
+ def analyze(text: str, *, language: str = "ja") -> dict[str, Any]:
29
+ """Analyze text into the public document dict.
30
+
31
+ Schema::
32
+
33
+ {
34
+ "language": "ja",
35
+ "text": "...",
36
+ "sentences": [
37
+ {"index": 1, "text": "...", "tokens": [...], "chunks": [...]}
38
+ ]
39
+ }
40
+
41
+ Chunks are siblings of tokens and reference them via ``tokenIndices``.
42
+ """
43
+ sentences = analyze_sentences(text, language=language)
44
+ return document_to_dict(text, sentences, language=language.lower().strip())
@@ -0,0 +1 @@
1
+ """Language backends. Japanese ships first; more languages later."""
@@ -0,0 +1,5 @@
1
+ """Japanese backend (GiNza / Sudachi)."""
2
+
3
+ from sentence_struct.backends.ja.segment import analyze
4
+
5
+ __all__ = ["analyze"]
@@ -0,0 +1,163 @@
1
+ """In-sentence chunking from dependency-based linear bunsetsu groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from spacy.tokens import Span, Token as SpacyToken
6
+
7
+ from sentence_struct.models import Chunk
8
+
9
+ _ATTACH_MAJORS = frozenset({"助詞", "助動詞", "接尾辞", "補助記号", "接続助詞"})
10
+
11
+ _PHRASE_BY_MAJOR = {
12
+ "名詞": "NP",
13
+ "代名詞": "NP",
14
+ "動詞": "VP",
15
+ "形容詞": "ADJP",
16
+ "形状詞": "ADJP",
17
+ "連体詞": "連体詞",
18
+ "副詞": "ADVP",
19
+ "助詞": "助詞",
20
+ "助動詞": "助動詞",
21
+ "補助記号": "補助記号",
22
+ }
23
+ _PHRASE_BY_POS = {
24
+ "NOUN": "NP",
25
+ "PROPN": "NP",
26
+ "PRON": "NP",
27
+ "NUM": "NP",
28
+ "VERB": "VP",
29
+ "AUX": "VP",
30
+ "ADJ": "ADJP",
31
+ "ADV": "ADVP",
32
+ }
33
+
34
+
35
+ def _pos_major(token: SpacyToken) -> str:
36
+ return token.tag_.split("-", 1)[0]
37
+
38
+
39
+ def _attaches_to_previous(token: SpacyToken, current_group: list[SpacyToken]) -> bool:
40
+ if token.pos_ == "PUNCT":
41
+ return True
42
+ if _pos_major(token) in _ATTACH_MAJORS:
43
+ return True
44
+ if token.dep_ in {
45
+ "fixed",
46
+ "mark",
47
+ "case",
48
+ "aux",
49
+ "cop",
50
+ "det",
51
+ "compound",
52
+ "clf",
53
+ }:
54
+ return True
55
+ if token.dep_ in {"advcl", "xcomp", "ccomp"} and token.head.i < token.i:
56
+ if any(t.i == token.head.i for t in current_group):
57
+ return True
58
+ return False
59
+
60
+
61
+ def _merge_nummod_groups(groups: list[list[SpacyToken]]) -> list[list[SpacyToken]]:
62
+ merged: list[list[SpacyToken]] = []
63
+ i = 0
64
+ while i < len(groups):
65
+ group = groups[i]
66
+ if i + 1 < len(groups):
67
+ nxt = groups[i + 1]
68
+ nxt_ids = {t.i for t in nxt}
69
+ if any(t.dep_ == "nummod" and t.head.i in nxt_ids for t in group):
70
+ merged.append(group + nxt)
71
+ i += 2
72
+ continue
73
+ merged.append(group)
74
+ i += 1
75
+ return merged
76
+
77
+
78
+ def _chunk_head(tokens: list[SpacyToken]) -> SpacyToken:
79
+ local = {t.i for t in tokens}
80
+ external_heads = [t for t in tokens if t.head.i not in local]
81
+ if external_heads:
82
+ return min(external_heads, key=lambda t: t.i)
83
+ roots = [t for t in tokens if t.dep_ == "ROOT"]
84
+ if roots:
85
+ return roots[0]
86
+ return tokens[0]
87
+
88
+
89
+ def _is_predicative(tokens: list[SpacyToken]) -> bool:
90
+ ids = {t.i for t in tokens}
91
+ for t in tokens:
92
+ if t.dep_ != "ROOT":
93
+ continue
94
+ root_major = _pos_major(t)
95
+ if root_major in ("動詞", "形容詞", "形状詞"):
96
+ return True
97
+ if root_major == "名詞":
98
+ has_suru_verb = any(_pos_major(x) == "動詞" and x.i in ids for x in tokens)
99
+ if has_suru_verb:
100
+ return True
101
+ return False
102
+
103
+
104
+ def _is_suru_phrase(tokens: list[SpacyToken]) -> bool:
105
+ for t in tokens:
106
+ if _pos_major(t) != "名詞":
107
+ continue
108
+ if any(x.head.i == t.i and _pos_major(x) == "動詞" for x in tokens):
109
+ return True
110
+ return False
111
+
112
+
113
+ def _is_adjectival_predicate(tokens: list[SpacyToken]) -> bool:
114
+ for t in tokens:
115
+ if t.dep_ == "ROOT" and _pos_major(t) == "名詞":
116
+ return any(_pos_major(x) == "助動詞" for x in tokens)
117
+ return False
118
+
119
+
120
+ def _chunk_type(head: SpacyToken, tokens: list[SpacyToken]) -> str:
121
+ if _is_predicative(tokens) or _is_suru_phrase(tokens):
122
+ return "VP"
123
+ if _is_adjectival_predicate(tokens):
124
+ return "ADJP"
125
+ if _pos_major(head) == "副詞" or head.dep_ == "advmod":
126
+ return "ADVP"
127
+ major = _pos_major(head)
128
+ if major in _PHRASE_BY_MAJOR:
129
+ return _PHRASE_BY_MAJOR[major]
130
+ return _PHRASE_BY_POS.get(head.pos_, "NP")
131
+
132
+
133
+ def _group_tokens(tokens: list[SpacyToken]) -> list[list[SpacyToken]]:
134
+ if not tokens:
135
+ return []
136
+ groups: list[list[SpacyToken]] = [[tokens[0]]]
137
+ for token in tokens[1:]:
138
+ if _attaches_to_previous(token, groups[-1]):
139
+ groups[-1].append(token)
140
+ else:
141
+ groups.append([token])
142
+ return groups
143
+
144
+
145
+ def build_chunks(span: Span) -> tuple[Chunk, ...]:
146
+ """Split a spaCy span into linearly adjacent chunks."""
147
+ tokens = [t for t in span if not t.is_space]
148
+ chunks: list[Chunk] = []
149
+ offset = span.start
150
+
151
+ for group in _merge_nummod_groups(_group_tokens(tokens)):
152
+ head = _chunk_head(group)
153
+ indices = tuple(t.i - offset for t in group)
154
+ chunks.append(
155
+ Chunk(
156
+ type=_chunk_type(head, group),
157
+ token_indices=indices,
158
+ text="".join(t.text for t in group),
159
+ head_index=head.i - offset,
160
+ role=head.dep_,
161
+ )
162
+ )
163
+ return tuple(chunks)
@@ -0,0 +1,222 @@
1
+ """Japanese sentence segmentation + tokenization (GiNza / Sudachi)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+
8
+ import spacy
9
+ from spacy.tokens import Doc, Span, Token as SpacyToken
10
+
11
+ from sentence_struct.backends.ja.chunks import build_chunks
12
+ from sentence_struct.backends.ja.token_merge import merge_tokens, remap_chunks
13
+ from sentence_struct.models import Chunk, Sentence, Token
14
+
15
+ MAX_SENTENCE_CHARS = 26
16
+ _SOFT_BREAK_CHARS = frozenset("、,,")
17
+ _HARD_SPLIT_RE = re.compile(r"\n+|(?<=[。!?])")
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _load_nlp() -> spacy.Language:
22
+ return spacy.load("ja_ginza")
23
+
24
+
25
+ def _reading(token: SpacyToken) -> str | None:
26
+ for feat in token.morph:
27
+ if feat.startswith("Reading="):
28
+ return feat.split("=", 1)[1]
29
+ return None
30
+
31
+
32
+ def _tokenize_span(span: Span, start_index: int = 1) -> tuple[Token, ...]:
33
+ tokens: list[Token] = []
34
+ for i, t in enumerate(span, start=start_index):
35
+ tokens.append(
36
+ Token(
37
+ index=i,
38
+ surface=t.text,
39
+ lemma=t.lemma_,
40
+ pos=t.pos_,
41
+ pos_detail=t.tag_,
42
+ reading=_reading(t),
43
+ )
44
+ )
45
+ return tuple(tokens)
46
+
47
+
48
+ def _finalize_tokens_and_chunks(
49
+ tokens: tuple[Token, ...],
50
+ chunks: tuple[Chunk, ...],
51
+ ) -> tuple[tuple[Token, ...], tuple[Chunk, ...]]:
52
+ merged_tokens, old_to_new = merge_tokens(tokens)
53
+ merged_chunks = remap_chunks(chunks, old_to_new, merged_tokens)
54
+ return merged_tokens, merged_chunks
55
+
56
+
57
+ def _coarse_units(text: str) -> list[str]:
58
+ return [p.strip() for p in _HARD_SPLIT_RE.split(text.strip()) if p.strip()]
59
+
60
+
61
+ def _pack_token_spans(doc: Doc, max_chars: int = MAX_SENTENCE_CHARS) -> list[Span]:
62
+ if not len(doc):
63
+ return []
64
+
65
+ chunks = build_chunks(doc[:])
66
+ if not chunks:
67
+ return _pack_by_tokens(doc, list(doc), max_chars)
68
+
69
+ groups = _pack_chunks(list(chunks), max_chars)
70
+ spans: list[Span] = []
71
+ for group in groups:
72
+ if not group:
73
+ continue
74
+ if len(group) == 1 and len(group[0].text) > max_chars:
75
+ c_start = group[0].token_indices[0]
76
+ c_end = group[0].token_indices[-1] + 1
77
+ spans.extend(_pack_by_tokens(doc, list(doc[c_start:c_end]), max_chars))
78
+ continue
79
+ start = group[0].token_indices[0]
80
+ end = group[-1].token_indices[-1] + 1
81
+ spans.append(doc[start:end])
82
+ return [s for s in spans if s.text.strip()]
83
+
84
+
85
+ def _pack_chunks(
86
+ chunks: list[Chunk],
87
+ max_chars: int,
88
+ min_chars: int = 8,
89
+ ) -> list[list[Chunk]]:
90
+ groups: list[list[Chunk]] = []
91
+ current: list[Chunk] = []
92
+ length = 0
93
+
94
+ for chunk in chunks:
95
+ clen = len(chunk.text)
96
+ if clen > max_chars and not current:
97
+ groups.append([chunk])
98
+ continue
99
+ if current and length + clen > max_chars:
100
+ groups.append(current)
101
+ current = [chunk]
102
+ length = clen
103
+ else:
104
+ current.append(chunk)
105
+ length += clen
106
+ if chunk.text[-1:] in _SOFT_BREAK_CHARS and length >= max_chars // 2:
107
+ groups.append(current)
108
+ current = []
109
+ length = 0
110
+ if current:
111
+ groups.append(current)
112
+
113
+ return _rebalance_chunk_groups(groups, max_chars, min_chars)
114
+
115
+
116
+ def _rebalance_chunk_groups(
117
+ groups: list[list[Chunk]],
118
+ max_chars: int,
119
+ min_chars: int,
120
+ ) -> list[list[Chunk]]:
121
+ if len(groups) < 2:
122
+ return groups
123
+
124
+ result = [list(g) for g in groups]
125
+ i = 1
126
+ while i < len(result):
127
+ right_len = sum(len(c.text) for c in result[i])
128
+ if right_len >= min_chars or len(result[i - 1]) <= 1:
129
+ i += 1
130
+ continue
131
+
132
+ while result[i - 1] and right_len < min_chars:
133
+ if len(result[i - 1]) <= 1:
134
+ break
135
+ stolen = result[i - 1][-1]
136
+ left_len = sum(len(c.text) for c in result[i - 1]) - len(stolen.text)
137
+ new_right = right_len + len(stolen.text)
138
+ if left_len < min_chars or new_right > max_chars:
139
+ break
140
+ result[i - 1].pop()
141
+ result[i].insert(0, stolen)
142
+ right_len = new_right
143
+
144
+ if not result[i - 1]:
145
+ result.pop(i - 1)
146
+ continue
147
+ i += 1
148
+
149
+ return [g for g in result if g]
150
+
151
+
152
+ def _pack_by_tokens(doc: Doc, tokens: list[SpacyToken], max_chars: int) -> list[Span]:
153
+ if not tokens:
154
+ return []
155
+
156
+ spans: list[Span] = []
157
+ group_start = tokens[0].i
158
+ length = 0
159
+
160
+ def flush(end_i: int) -> None:
161
+ nonlocal group_start, length
162
+ if end_i > group_start:
163
+ spans.append(doc[group_start:end_i])
164
+ group_start = end_i
165
+ length = 0
166
+
167
+ for tok in tokens:
168
+ tlen = len(tok.text)
169
+ if tlen > max_chars and length == 0:
170
+ spans.append(doc[tok.i : tok.i + 1])
171
+ group_start = tok.i + 1
172
+ length = 0
173
+ continue
174
+ if length and length + tlen > max_chars:
175
+ flush(tok.i)
176
+ length += tlen
177
+ if tok.text in _SOFT_BREAK_CHARS and length >= max_chars // 2:
178
+ flush(tok.i + 1)
179
+
180
+ flush(tokens[-1].i + 1)
181
+ return spans
182
+
183
+
184
+ def _sentence_from_span(span: Span, index: int, method: str) -> Sentence | None:
185
+ stripped = span.text.strip()
186
+ if not stripped:
187
+ return None
188
+ raw_tokens = _tokenize_span(span)
189
+ raw_chunks = build_chunks(span)
190
+ tokens, chunks = _finalize_tokens_and_chunks(raw_tokens, raw_chunks)
191
+ return Sentence(
192
+ index=index,
193
+ text=stripped,
194
+ char_count=len(stripped),
195
+ method=method,
196
+ tokens=tokens,
197
+ chunks=chunks,
198
+ )
199
+
200
+
201
+ def analyze(text: str) -> list[Sentence]:
202
+ """Segment and tokenize Japanese text.
203
+
204
+ Strategy:
205
+ 1. Hard split on newlines and 。!?
206
+ 2. If still over MAX_SENTENCE_CHARS, pack by chunks
207
+ 3. Rebalance short tails by borrowing chunks from the left
208
+ """
209
+ nlp = _load_nlp()
210
+ sentences: list[Sentence] = []
211
+ for unit in _coarse_units(text):
212
+ doc = nlp(unit)
213
+ spans = (
214
+ [doc[:]]
215
+ if len(unit) <= MAX_SENTENCE_CHARS
216
+ else _pack_token_spans(doc, MAX_SENTENCE_CHARS)
217
+ )
218
+ for span in spans:
219
+ sent = _sentence_from_span(span, len(sentences) + 1, method="ginza")
220
+ if sent is not None:
221
+ sentences.append(sent)
222
+ return sentences
@@ -0,0 +1,90 @@
1
+ """Token / Chunk type normalization for UI highlighting groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ TOKEN_POS_GROUPS: dict[str, str] = {
6
+ "名詞": "名詞",
7
+ "代名詞": "名詞",
8
+ "数詞": "名詞",
9
+ "動詞": "動詞",
10
+ "形容詞": "形容詞",
11
+ "形状詞": "形容詞",
12
+ "連体詞": "形容詞",
13
+ "副詞": "副詞",
14
+ "接続詞": "副詞",
15
+ "助詞": "助詞",
16
+ "助動詞": "助動詞",
17
+ "感動詞": "感動詞",
18
+ "接頭辞": "_",
19
+ "接尾辞": "_",
20
+ "補助記号": "_",
21
+ "記号": "_",
22
+ "空白": "_",
23
+ "フィラー": "_",
24
+ "未知語": "_",
25
+ }
26
+
27
+ _TOKEN_UPOS_GROUPS: dict[str, str] = {
28
+ "NOUN": "名詞",
29
+ "PROPN": "名詞",
30
+ "PRON": "名詞",
31
+ "NUM": "名詞",
32
+ "VERB": "動詞",
33
+ "ADJ": "形容詞",
34
+ "ADV": "副詞",
35
+ "ADP": "助詞",
36
+ "AUX": "助動詞",
37
+ "SCONJ": "副詞",
38
+ "CCONJ": "副詞",
39
+ "INTJ": "感動詞",
40
+ "PUNCT": "_",
41
+ "X": "_",
42
+ }
43
+
44
+ CHUNK_TYPE_GROUPS: dict[str, str] = {
45
+ "NP": "NP",
46
+ "VP": "VP",
47
+ "ADJP": "ADJP",
48
+ "ADVP": "ADVP",
49
+ "PP": "ADVP",
50
+ "CP": "VP",
51
+ "CCONJP": "ADVP",
52
+ "名詞": "NP",
53
+ "代名詞": "NP",
54
+ "動詞": "VP",
55
+ "形容詞": "ADJP",
56
+ "形状詞": "ADJP",
57
+ "連体詞": "ADJP",
58
+ "副詞": "ADVP",
59
+ "助詞": "助詞",
60
+ "助動詞": "VP",
61
+ "感動詞": "感動詞",
62
+ "補助記号": "_",
63
+ }
64
+
65
+ TOKEN_HIGHLIGHT_KEYS = ("名詞", "動詞", "形容詞", "副詞", "助詞", "助動詞", "感動詞")
66
+ CHUNK_HIGHLIGHT_KEYS = ("NP", "VP", "ADJP", "ADVP", "助詞", "感動詞")
67
+ SUDACHI_POS_MAJORS = frozenset(TOKEN_POS_GROUPS)
68
+ CHUNK_SOURCE_TYPES = frozenset(CHUNK_TYPE_GROUPS)
69
+
70
+
71
+ def normalize_token_pos(pos: str, *, pos_detail: str | None = None) -> str:
72
+ """Sudachi major POS → posGroup."""
73
+ major = pos.split("-", 1)[0] if pos else ""
74
+ if major in TOKEN_POS_GROUPS:
75
+ return TOKEN_POS_GROUPS[major]
76
+ if pos_detail:
77
+ detail_major = pos_detail.split("-", 1)[0]
78
+ if detail_major in TOKEN_POS_GROUPS:
79
+ return TOKEN_POS_GROUPS[detail_major]
80
+ return "_"
81
+
82
+
83
+ def normalize_token_upos(pos: str) -> str:
84
+ """Universal POS → posGroup."""
85
+ return _TOKEN_UPOS_GROUPS.get(pos, "_")
86
+
87
+
88
+ def normalize_chunk_type(chunk_type: str) -> str:
89
+ """chunk type → typeGroup."""
90
+ return CHUNK_TYPE_GROUPS.get(chunk_type, "_")
@@ -0,0 +1,125 @@
1
+ """Merge morpheme tokens into learner-friendly word surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sentence_struct.models import Chunk, Token
6
+
7
+ _PREDICATE_HEAD_MAJORS = frozenset({"動詞", "形容詞", "形状詞", "助動詞"})
8
+ _MERGE_ATTACH_MAJORS = frozenset({"助動詞", "接尾辞"})
9
+
10
+
11
+ def _pos_major(pos_detail: str) -> str:
12
+ return pos_detail.split("-", 1)[0]
13
+
14
+
15
+ def _merges_into_previous(prev: Token, curr: Token) -> bool:
16
+ prev_major = _pos_major(prev.pos_detail)
17
+ curr_major = _pos_major(curr.pos_detail)
18
+
19
+ if curr_major in _MERGE_ATTACH_MAJORS:
20
+ return prev_major in _PREDICATE_HEAD_MAJORS
21
+
22
+ if curr_major == "形容詞" and prev_major == "形容詞":
23
+ return True
24
+
25
+ if curr_major == "助詞" and curr.surface in {"て", "で"} and prev_major == "動詞":
26
+ return True
27
+
28
+ if curr_major == "動詞" and prev.surface.endswith(("て", "で")):
29
+ return True
30
+
31
+ return False
32
+
33
+
34
+ def _join_reading(prev: str | None, curr: str | None) -> str | None:
35
+ merged = (prev or "") + (curr or "")
36
+ return merged or None
37
+
38
+
39
+ def merge_tokens(tokens: tuple[Token, ...]) -> tuple[tuple[Token, ...], list[int]]:
40
+ """Merge morphemes; return (merged_tokens, old_to_new index map)."""
41
+ if not tokens:
42
+ return (), []
43
+
44
+ merged: list[Token] = []
45
+ old_to_new: list[int] = []
46
+
47
+ for token in tokens:
48
+ if merged and _merges_into_previous(merged[-1], token):
49
+ prev = merged[-1]
50
+ merged[-1] = Token(
51
+ index=prev.index,
52
+ surface=prev.surface + token.surface,
53
+ lemma=prev.lemma,
54
+ pos=prev.pos,
55
+ pos_detail=prev.pos_detail,
56
+ reading=_join_reading(prev.reading, token.reading),
57
+ )
58
+ old_to_new.append(len(merged) - 1)
59
+ continue
60
+
61
+ old_to_new.append(len(merged))
62
+ merged.append(token)
63
+
64
+ reindexed = tuple(
65
+ Token(
66
+ index=i + 1,
67
+ surface=t.surface,
68
+ lemma=t.lemma,
69
+ pos=t.pos,
70
+ pos_detail=t.pos_detail,
71
+ reading=t.reading,
72
+ )
73
+ for i, t in enumerate(merged)
74
+ )
75
+ return reindexed, old_to_new
76
+
77
+
78
+ def remap_chunks(
79
+ chunks: tuple[Chunk, ...],
80
+ old_to_new: list[int],
81
+ merged_tokens: tuple[Token, ...],
82
+ ) -> tuple[Chunk, ...]:
83
+ """Remap chunk indices/text after token merge."""
84
+ remapped: list[Chunk] = []
85
+ seen_keys: set[tuple[int, ...]] = set()
86
+
87
+ for chunk in chunks:
88
+ seen: set[int] = set()
89
+ indices: list[int] = []
90
+ for old_index in chunk.token_indices:
91
+ new_index = old_to_new[old_index]
92
+ if new_index in seen:
93
+ continue
94
+ seen.add(new_index)
95
+ indices.append(new_index)
96
+
97
+ if not indices:
98
+ continue
99
+
100
+ key = tuple(indices)
101
+ if key in seen_keys:
102
+ continue
103
+ seen_keys.add(key)
104
+
105
+ remapped.append(
106
+ Chunk(
107
+ type=chunk.type,
108
+ token_indices=tuple(indices),
109
+ text="".join(merged_tokens[i].surface for i in indices),
110
+ head_index=old_to_new[chunk.head_index],
111
+ role=chunk.role,
112
+ )
113
+ )
114
+
115
+ return _drop_subset_chunks(remapped)
116
+
117
+
118
+ def _drop_subset_chunks(chunks: list[Chunk]) -> tuple[Chunk, ...]:
119
+ kept: list[Chunk] = []
120
+ index_sets = [set(chunk.token_indices) for chunk in chunks]
121
+ for i, chunk in enumerate(chunks):
122
+ if any(index_sets[i] < index_sets[j] for j in range(len(chunks)) if i != j):
123
+ continue
124
+ kept.append(chunk)
125
+ return tuple(kept)
sentence_struct/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """CLI: sentence-struct [options] [text]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from sentence_struct.api import SUPPORTED_LANGUAGES, analyze
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="sentence-struct",
16
+ description="Analyze text into sentences with tokens and syntactic chunks.",
17
+ )
18
+ parser.add_argument(
19
+ "text",
20
+ nargs="?",
21
+ help="Input text. Omit when using --file.",
22
+ )
23
+ parser.add_argument(
24
+ "-f",
25
+ "--file",
26
+ type=Path,
27
+ help="Read input text from a UTF-8 file.",
28
+ )
29
+ parser.add_argument(
30
+ "-o",
31
+ "--output",
32
+ type=Path,
33
+ help="Write JSON to this path (default: stdout).",
34
+ )
35
+ parser.add_argument(
36
+ "-l",
37
+ "--language",
38
+ default="ja",
39
+ help=f"Language code (default: ja). Supported: {', '.join(sorted(SUPPORTED_LANGUAGES))}",
40
+ )
41
+ parser.add_argument(
42
+ "--indent",
43
+ type=int,
44
+ default=2,
45
+ help="JSON indent (default: 2).",
46
+ )
47
+ args = parser.parse_args(argv)
48
+
49
+ if args.file is not None:
50
+ text = args.file.read_text(encoding="utf-8").strip()
51
+ elif args.text is not None:
52
+ text = args.text.strip()
53
+ else:
54
+ if sys.stdin.isatty():
55
+ parser.error("Provide text, --file, or pipe stdin.")
56
+ text = sys.stdin.read().strip()
57
+
58
+ if not text:
59
+ parser.error("Input text is empty.")
60
+
61
+ result = analyze(text, language=args.language)
62
+ payload = json.dumps(result, ensure_ascii=False, indent=args.indent) + "\n"
63
+
64
+ if args.output is not None:
65
+ args.output.parent.mkdir(parents=True, exist_ok=True)
66
+ args.output.write_text(payload, encoding="utf-8")
67
+ else:
68
+ sys.stdout.write(payload)
69
+
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ """Shared dataclasses for sentence structure analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Token:
10
+ """Word / morpheme unit inside a sentence."""
11
+
12
+ index: int
13
+ surface: str
14
+ lemma: str
15
+ pos: str
16
+ pos_detail: str
17
+ reading: str | None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Chunk:
22
+ """Linear syntactic chunk that references token indices in the same sentence."""
23
+
24
+ type: str
25
+ token_indices: tuple[int, ...]
26
+ text: str
27
+ head_index: int
28
+ role: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Sentence:
33
+ """One display sentence with sibling token and chunk layers."""
34
+
35
+ index: int
36
+ text: str
37
+ char_count: int
38
+ method: str
39
+ tokens: tuple[Token, ...] = ()
40
+ chunks: tuple[Chunk, ...] = ()
@@ -0,0 +1,64 @@
1
+ """Serialize Sentence objects to the public JSON-friendly dict schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sentence_struct.backends.ja.taxonomy import normalize_chunk_type, normalize_token_pos
8
+ from sentence_struct.models import Sentence
9
+
10
+
11
+ def _pos_major_from_detail(pos_detail: str) -> str:
12
+ return pos_detail.split("-", 1)[0]
13
+
14
+
15
+ def sentence_to_dict(sentence: Sentence) -> dict[str, Any]:
16
+ tokens = []
17
+ for t in sentence.tokens:
18
+ pos = _pos_major_from_detail(t.pos_detail)
19
+ tokens.append(
20
+ {
21
+ "text": t.surface,
22
+ "pos": pos,
23
+ "posGroup": normalize_token_pos(pos, pos_detail=t.pos_detail),
24
+ "pos_detail": t.pos_detail,
25
+ "lemma": t.lemma,
26
+ "reading": t.reading,
27
+ }
28
+ )
29
+ chunks = [
30
+ {
31
+ "type": c.type,
32
+ "typeGroup": normalize_chunk_type(c.type),
33
+ "tokenIndices": list(c.token_indices),
34
+ "text": c.text,
35
+ "headIndex": c.head_index,
36
+ "role": c.role,
37
+ }
38
+ for c in sentence.chunks
39
+ ]
40
+ return {
41
+ "index": sentence.index,
42
+ "text": sentence.text,
43
+ "char_count": sentence.char_count,
44
+ "method": sentence.method,
45
+ "tokens": tokens,
46
+ "chunks": chunks,
47
+ }
48
+
49
+
50
+ def document_to_dict(
51
+ text: str,
52
+ sentences: list[Sentence],
53
+ *,
54
+ language: str,
55
+ ) -> dict[str, Any]:
56
+ return {
57
+ "language": language,
58
+ "text": text,
59
+ "char_count": len(text),
60
+ "sentence_count": len(sentences),
61
+ "token_count": sum(len(s.tokens) for s in sentences),
62
+ "chunk_count": sum(len(s.chunks) for s in sentences),
63
+ "sentences": [sentence_to_dict(s) for s in sentences],
64
+ }
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: sentence-struct
3
+ Version: 0.1.0
4
+ Summary: Multilingual sentence structure analysis: sentences with tokens and syntactic chunks
5
+ Project-URL: Homepage, https://github.com/memshare-project/sentence-struct
6
+ Project-URL: Repository, https://github.com/memshare-project/sentence-struct
7
+ Project-URL: Issues, https://github.com/memshare-project/sentence-struct/issues
8
+ Project-URL: Changelog, https://github.com/memshare-project/sentence-struct/blob/main/CHANGELOG.md
9
+ Author: sentence-struct contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: chunking,japanese,linguistics,nlp,sentence-structure,tokenization
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Text Processing :: Linguistic
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: ginza>=5.2.1
23
+ Requires-Dist: ja-ginza>=5.2.0
24
+ Requires-Dist: spacy>=3.8.0
25
+ Requires-Dist: sudachidict-core>=20240409
26
+ Requires-Dist: sudachipy>=0.6.8
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Provides-Extra: ja-full
31
+ Requires-Dist: sudachidict-full>=20240409; extra == 'ja-full'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # sentence-struct
35
+
36
+ Analyze text into a learner-friendly structure:
37
+
38
+ **document → sentences[] → { tokens[], chunks[] }**
39
+
40
+ Chunks are siblings of tokens and reference them via `tokenIndices` (not nested).
41
+
42
+ Japanese (`ja`) is implemented first via GiNza / Sudachi. More languages later.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install sentence-struct
48
+ # or
49
+ uv add sentence-struct
50
+ ```
51
+
52
+ Optional larger Sudachi dictionary:
53
+
54
+ ```bash
55
+ pip install "sentence-struct[ja-full]"
56
+ ```
57
+
58
+ Requires Python ≥ 3.11. First install pulls spaCy / GiNza / Sudachi (hundreds of MB).
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ from sentence_struct import analyze
64
+
65
+ doc = analyze("秋が近づくにつれ、朝晩の涼しさが心地よい。", language="ja")
66
+ print(doc["sentence_count"], doc["token_count"], doc["chunk_count"])
67
+ print(doc["sentences"][0]["tokens"][0])
68
+ print(doc["sentences"][0]["chunks"][0])
69
+ ```
70
+
71
+ CLI:
72
+
73
+ ```bash
74
+ sentence-struct "秋が近づく。"
75
+ sentence-struct -f essay.txt -o out.json
76
+ ```
77
+
78
+ ## Schema (abbreviated)
79
+
80
+ ```json
81
+ {
82
+ "language": "ja",
83
+ "text": "...",
84
+ "sentences": [
85
+ {
86
+ "index": 1,
87
+ "text": "...",
88
+ "tokens": [
89
+ {"text": "秋", "pos": "名詞", "posGroup": "名詞", "lemma": "秋", "reading": "アキ"}
90
+ ],
91
+ "chunks": [
92
+ {"type": "NP", "typeGroup": "NP", "tokenIndices": [0, 1], "text": "秋が", "role": "nsubj"}
93
+ ]
94
+ }
95
+ ]
96
+ }
97
+ ```
98
+
99
+ ## Develop (uv)
100
+
101
+ ```bash
102
+ uv sync
103
+ uv run pytest -m "not integration"
104
+ uv run pytest -m integration
105
+ uv run sentence-struct "今日は良い天気です。"
106
+ ```
107
+
108
+ ## Release
109
+
110
+ See [RELEASING.md](RELEASING.md). Tag `vX.Y.Z` → GitHub Actions builds with **uv** and publishes to PyPI via Trusted Publishing.
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,16 @@
1
+ sentence_struct/__init__.py,sha256=sXaBB5YZUejoqVBp1ZS4kzQd8CrLK5gNxPyG86yq76k,351
2
+ sentence_struct/api.py,sha256=59pNPMuqKuAS1ANBJV-LOuy5srVpiPX0SAE5ddYPg8k,1372
3
+ sentence_struct/cli.py,sha256=Ot8KdaPaMwsi6nRw_WXqXfQ7HKl-2qH2FbqP5Aj2Mig,1932
4
+ sentence_struct/models.py,sha256=1J8tJh2ZddB2f93DvROGcsXDwAAzvS8qNodQlN5RdvE,791
5
+ sentence_struct/serialize.py,sha256=ZtAW5RvBpzZMEEInQwaa-w5YEjUPfyHPLtwhuFBsOeM,1807
6
+ sentence_struct/backends/__init__.py,sha256=LKpvISA-W-9ghcpzoHFagwIAs_DFZvXj_bbrKo7jtjg,69
7
+ sentence_struct/backends/ja/__init__.py,sha256=ysQT-BfWDlep2Gb4WtuAwil-juQAk3XekDmBM20WJUw,122
8
+ sentence_struct/backends/ja/chunks.py,sha256=6OSO-3lvbOHVIRdl14IHl0b1qG9uSHw0j55bXtASvQE,4696
9
+ sentence_struct/backends/ja/segment.py,sha256=ftp9SuGW9KOeYAYjUMpYBVpWreOTAcsOw1Cm76Ba98Q,6476
10
+ sentence_struct/backends/ja/taxonomy.py,sha256=EDISS7JlXSWkNEmhlJSYgKoOFCNZP7yg59beCWNB5A0,2361
11
+ sentence_struct/backends/ja/token_merge.py,sha256=UKAN5RVg60dt0a1O3ThhmJtixcw5Ms5QLRhSvQhvLDo,3645
12
+ sentence_struct-0.1.0.dist-info/METADATA,sha256=C_BzcpNWKIj9DwqNYdWu4yIWiUpJf0ci36xMPaYrxJU,3131
13
+ sentence_struct-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ sentence_struct-0.1.0.dist-info/entry_points.txt,sha256=DC8FZqHbJvFi-N2J4dH8jEHhMzChYZvtLtRfXICKAts,61
15
+ sentence_struct-0.1.0.dist-info/licenses/LICENSE,sha256=RAXd024Sqp6H5sF6exAlGSyS75yYJOYfMuEVRHAGoo0,1085
16
+ sentence_struct-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sentence-struct = sentence_struct.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sentence-struct contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.