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.
- sentence_struct/__init__.py +15 -0
- sentence_struct/api.py +44 -0
- sentence_struct/backends/__init__.py +1 -0
- sentence_struct/backends/ja/__init__.py +5 -0
- sentence_struct/backends/ja/chunks.py +163 -0
- sentence_struct/backends/ja/segment.py +222 -0
- sentence_struct/backends/ja/taxonomy.py +90 -0
- sentence_struct/backends/ja/token_merge.py +125 -0
- sentence_struct/cli.py +74 -0
- sentence_struct/models.py +40 -0
- sentence_struct/serialize.py +64 -0
- sentence_struct-0.1.0.dist-info/METADATA +114 -0
- sentence_struct-0.1.0.dist-info/RECORD +16 -0
- sentence_struct-0.1.0.dist-info/WHEEL +4 -0
- sentence_struct-0.1.0.dist-info/entry_points.txt +2 -0
- sentence_struct-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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.
|