Autarkia-Textkit 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.
- autarkia_textkit/__init__.py +100 -0
- autarkia_textkit/clean.py +152 -0
- autarkia_textkit/emoji.py +85 -0
- autarkia_textkit/force.py +128 -0
- autarkia_textkit/merge.py +218 -0
- autarkia_textkit/numbered.py +95 -0
- autarkia_textkit/py.typed +0 -0
- autarkia_textkit/rules.py +65 -0
- autarkia_textkit/segmenter.py +445 -0
- autarkia_textkit/similarity.py +79 -0
- autarkia_textkit-0.1.0.dist-info/METADATA +266 -0
- autarkia_textkit-0.1.0.dist-info/RECORD +14 -0
- autarkia_textkit-0.1.0.dist-info/WHEEL +4 -0
- autarkia_textkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Autarkia-Textkit: Chinese chat text segmentation and set similarity."""
|
|
2
|
+
|
|
3
|
+
from .clean import (
|
|
4
|
+
clean_chat_text,
|
|
5
|
+
is_ellipsis_only,
|
|
6
|
+
split_for_display,
|
|
7
|
+
strip_ai_timestamp,
|
|
8
|
+
strip_trailing_punctuation,
|
|
9
|
+
)
|
|
10
|
+
from .emoji import EMOJI_RANGES, is_emoji_char
|
|
11
|
+
from .force import (
|
|
12
|
+
force_split_long_sentence,
|
|
13
|
+
has_unclosed_inline_span,
|
|
14
|
+
repair_broken_inline_spans,
|
|
15
|
+
)
|
|
16
|
+
from .merge import (
|
|
17
|
+
HAS_JIEBA,
|
|
18
|
+
glue_cjk_ascii_spaces,
|
|
19
|
+
is_continuation_start,
|
|
20
|
+
looks_like_manual_space_split,
|
|
21
|
+
merge_chunks_to_limit,
|
|
22
|
+
merge_continuation_chunks,
|
|
23
|
+
merge_space_chunks_to_limit,
|
|
24
|
+
)
|
|
25
|
+
from .numbered import (
|
|
26
|
+
find_numbered_list_starts,
|
|
27
|
+
is_numbered_item_start,
|
|
28
|
+
normalize_numbered_list,
|
|
29
|
+
)
|
|
30
|
+
from .rules import (
|
|
31
|
+
AI_TIMESTAMP_PATTERN,
|
|
32
|
+
AI_TIMESTAMP_PATTERN_SOURCE,
|
|
33
|
+
CONTINUATION_ADVERBS,
|
|
34
|
+
CONTINUATION_ENDINGS,
|
|
35
|
+
ELLIPSIS_MERGE_PREFIX_LIMIT,
|
|
36
|
+
EXPLICIT_SPACE_BOUNDARY_ENDINGS,
|
|
37
|
+
HARD_BUBBLE_BOUNDARY_ENDINGS,
|
|
38
|
+
PUNCTUATION_FOR_SPACE_GUARD,
|
|
39
|
+
TRAILING_PUNCT_CHARS,
|
|
40
|
+
)
|
|
41
|
+
from .segmenter import split_chat_message
|
|
42
|
+
from .similarity import (
|
|
43
|
+
char_ngrams,
|
|
44
|
+
cjk_bigrams,
|
|
45
|
+
cjk_chars,
|
|
46
|
+
containment,
|
|
47
|
+
intersection_over_max,
|
|
48
|
+
jaccard,
|
|
49
|
+
token_ngrams,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"__version__",
|
|
56
|
+
# rules
|
|
57
|
+
"AI_TIMESTAMP_PATTERN",
|
|
58
|
+
"AI_TIMESTAMP_PATTERN_SOURCE",
|
|
59
|
+
"CONTINUATION_ADVERBS",
|
|
60
|
+
"CONTINUATION_ENDINGS",
|
|
61
|
+
"ELLIPSIS_MERGE_PREFIX_LIMIT",
|
|
62
|
+
"EXPLICIT_SPACE_BOUNDARY_ENDINGS",
|
|
63
|
+
"HARD_BUBBLE_BOUNDARY_ENDINGS",
|
|
64
|
+
"PUNCTUATION_FOR_SPACE_GUARD",
|
|
65
|
+
"TRAILING_PUNCT_CHARS",
|
|
66
|
+
# emoji
|
|
67
|
+
"EMOJI_RANGES",
|
|
68
|
+
"is_emoji_char",
|
|
69
|
+
# cleaning / display
|
|
70
|
+
"clean_chat_text",
|
|
71
|
+
"is_ellipsis_only",
|
|
72
|
+
"split_for_display",
|
|
73
|
+
"strip_ai_timestamp",
|
|
74
|
+
"strip_trailing_punctuation",
|
|
75
|
+
# splitting
|
|
76
|
+
"split_chat_message",
|
|
77
|
+
"force_split_long_sentence",
|
|
78
|
+
"repair_broken_inline_spans",
|
|
79
|
+
"has_unclosed_inline_span",
|
|
80
|
+
# numbered lists
|
|
81
|
+
"find_numbered_list_starts",
|
|
82
|
+
"is_numbered_item_start",
|
|
83
|
+
"normalize_numbered_list",
|
|
84
|
+
# merging
|
|
85
|
+
"HAS_JIEBA",
|
|
86
|
+
"is_continuation_start",
|
|
87
|
+
"glue_cjk_ascii_spaces",
|
|
88
|
+
"looks_like_manual_space_split",
|
|
89
|
+
"merge_chunks_to_limit",
|
|
90
|
+
"merge_continuation_chunks",
|
|
91
|
+
"merge_space_chunks_to_limit",
|
|
92
|
+
# similarity
|
|
93
|
+
"intersection_over_max",
|
|
94
|
+
"containment",
|
|
95
|
+
"jaccard",
|
|
96
|
+
"char_ngrams",
|
|
97
|
+
"token_ngrams",
|
|
98
|
+
"cjk_chars",
|
|
99
|
+
"cjk_bigrams",
|
|
100
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Cleaning and display grouping: timestamps, trailing punctuation, blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .rules import (
|
|
8
|
+
AI_TIMESTAMP_PATTERN,
|
|
9
|
+
TRAILING_PUNCT_CHARS,
|
|
10
|
+
_LATEX_MARKER_RE,
|
|
11
|
+
_MARKDOWN_BLOCK_START_RE,
|
|
12
|
+
_MARKDOWN_TABLE_LINE_RE,
|
|
13
|
+
)
|
|
14
|
+
from .segmenter import split_chat_message
|
|
15
|
+
|
|
16
|
+
_ELLIPSIS_ONLY_RE = re.compile(r"^[\s.…]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_ellipsis_only(text: str) -> bool:
|
|
20
|
+
"""Whether the whole text consists only of ellipses and whitespace."""
|
|
21
|
+
stripped = str(text or "").strip()
|
|
22
|
+
if not stripped or not _ELLIPSIS_ONLY_RE.match(stripped):
|
|
23
|
+
return False
|
|
24
|
+
return "…" in stripped or stripped.count(".") >= 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_ai_timestamp(text: str) -> str:
|
|
28
|
+
"""Remove model-emitted history timestamps anywhere in the text."""
|
|
29
|
+
return AI_TIMESTAMP_PATTERN.sub("", str(text or "")).strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strip_trailing_punctuation(text: str) -> str:
|
|
33
|
+
"""Remove trailing punctuation without touching ellipses."""
|
|
34
|
+
text = str(text or "")
|
|
35
|
+
if is_ellipsis_only(text):
|
|
36
|
+
return text.strip()
|
|
37
|
+
text = re.sub(r"[。.]\s*(?=[((])", "", text)
|
|
38
|
+
text = re.sub(r"[。.]+\s*([))])", r"\1", text)
|
|
39
|
+
text = text.rstrip(TRAILING_PUNCT_CHARS).strip()
|
|
40
|
+
text = re.sub(r"[。.]+$", "", text)
|
|
41
|
+
return text.strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean_chat_text(text: str) -> str:
|
|
45
|
+
"""Timestamp removal + trailing punctuation removal."""
|
|
46
|
+
return strip_trailing_punctuation(strip_ai_timestamp(text))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _group_display_lines(text: str) -> list[tuple[str, bool]]:
|
|
50
|
+
"""Split into ``(text, atomic)`` groups by Markdown block structure.
|
|
51
|
+
|
|
52
|
+
Fenced code, ``$$`` blocks and table runs stay whole; single-line block
|
|
53
|
+
markers (heading/list/quote) stay on their line. Ordinary lines still go
|
|
54
|
+
through :func:`split_chat_message`.
|
|
55
|
+
"""
|
|
56
|
+
lines = text.split("\n")
|
|
57
|
+
groups: list[tuple[str, bool]] = []
|
|
58
|
+
index = 0
|
|
59
|
+
total = len(lines)
|
|
60
|
+
|
|
61
|
+
while index < total:
|
|
62
|
+
line = lines[index]
|
|
63
|
+
stripped = line.strip()
|
|
64
|
+
if not stripped:
|
|
65
|
+
index += 1
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
fence = None
|
|
69
|
+
if stripped.startswith("```"):
|
|
70
|
+
fence = "```"
|
|
71
|
+
elif stripped.startswith("~~~"):
|
|
72
|
+
fence = "~~~"
|
|
73
|
+
if fence is not None:
|
|
74
|
+
buffer: list[str] = []
|
|
75
|
+
cursor = index
|
|
76
|
+
while cursor < total:
|
|
77
|
+
buffer.append(lines[cursor])
|
|
78
|
+
cursor += 1
|
|
79
|
+
if cursor > index + 1 and lines[cursor - 1].strip().startswith(fence):
|
|
80
|
+
break
|
|
81
|
+
groups.append(("\n".join(buffer), True))
|
|
82
|
+
index = cursor
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if stripped == "$$":
|
|
86
|
+
buffer = []
|
|
87
|
+
cursor = index
|
|
88
|
+
while cursor < total:
|
|
89
|
+
buffer.append(lines[cursor])
|
|
90
|
+
cursor += 1
|
|
91
|
+
if cursor > index + 1 and lines[cursor - 1].strip() == "$$":
|
|
92
|
+
break
|
|
93
|
+
groups.append(("\n".join(buffer), True))
|
|
94
|
+
index = cursor
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if _MARKDOWN_TABLE_LINE_RE.match(line):
|
|
98
|
+
buffer = []
|
|
99
|
+
cursor = index
|
|
100
|
+
while cursor < total and _MARKDOWN_TABLE_LINE_RE.match(lines[cursor]):
|
|
101
|
+
buffer.append(lines[cursor].strip())
|
|
102
|
+
cursor += 1
|
|
103
|
+
groups.append(("\n".join(buffer), True))
|
|
104
|
+
index = cursor
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
atomic = bool(
|
|
108
|
+
_MARKDOWN_BLOCK_START_RE.search(stripped) or _LATEX_MARKER_RE.search(stripped)
|
|
109
|
+
)
|
|
110
|
+
groups.append((stripped, atomic))
|
|
111
|
+
index += 1
|
|
112
|
+
|
|
113
|
+
return groups
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def split_for_display(
|
|
117
|
+
text: str,
|
|
118
|
+
max_len: int = 150,
|
|
119
|
+
comma_split_prob: float = 0.2,
|
|
120
|
+
min_split_len: int = 40,
|
|
121
|
+
rng=None,
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
"""Clean, group by structure, split each group, clean each bubble again."""
|
|
124
|
+
cleaned = clean_chat_text(text)
|
|
125
|
+
if not cleaned:
|
|
126
|
+
return []
|
|
127
|
+
result: list[str] = []
|
|
128
|
+
for group_text, atomic in _group_display_lines(cleaned):
|
|
129
|
+
if atomic:
|
|
130
|
+
if group_text.strip():
|
|
131
|
+
result.append(group_text)
|
|
132
|
+
continue
|
|
133
|
+
for chunk in split_chat_message(
|
|
134
|
+
group_text,
|
|
135
|
+
max_len,
|
|
136
|
+
comma_split_prob,
|
|
137
|
+
min_split_len,
|
|
138
|
+
rng=rng,
|
|
139
|
+
):
|
|
140
|
+
chunk = strip_trailing_punctuation(chunk)
|
|
141
|
+
if chunk:
|
|
142
|
+
result.append(chunk)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"is_ellipsis_only",
|
|
148
|
+
"strip_ai_timestamp",
|
|
149
|
+
"strip_trailing_punctuation",
|
|
150
|
+
"clean_chat_text",
|
|
151
|
+
"split_for_display",
|
|
152
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Emoji ranges; a punctuation-adjacent emoji must stay with the current bubble."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Precise ranges, so Chinese punctuation is never mistaken for an emoji.
|
|
6
|
+
EMOJI_RANGES = [
|
|
7
|
+
(0x1F600, 0x1F64F),
|
|
8
|
+
(0x1F300, 0x1F5FF),
|
|
9
|
+
(0x1F680, 0x1F6FF),
|
|
10
|
+
(0x1F1E0, 0x1F1FF),
|
|
11
|
+
(0x1F900, 0x1F9FF),
|
|
12
|
+
(0x1FA00, 0x1FA6F),
|
|
13
|
+
(0x1FA70, 0x1FAFF),
|
|
14
|
+
(0x2600, 0x26FF),
|
|
15
|
+
(0x2700, 0x27BF),
|
|
16
|
+
(0x2300, 0x23FF),
|
|
17
|
+
(0x2B50, 0x2B55),
|
|
18
|
+
(0x203C, 0x2049),
|
|
19
|
+
(0x2122, 0x2122),
|
|
20
|
+
(0x2194, 0x21AA),
|
|
21
|
+
(0x231A, 0x231B),
|
|
22
|
+
(0x23E9, 0x23F3),
|
|
23
|
+
(0x23F8, 0x23FA),
|
|
24
|
+
(0x25AA, 0x25AB),
|
|
25
|
+
(0x25B6, 0x25C0),
|
|
26
|
+
(0x25FB, 0x25FE),
|
|
27
|
+
(0x2614, 0x2615),
|
|
28
|
+
(0x2648, 0x2653),
|
|
29
|
+
(0x267F, 0x267F),
|
|
30
|
+
(0x2693, 0x2693),
|
|
31
|
+
(0x26A1, 0x26A1),
|
|
32
|
+
(0x26AA, 0x26AB),
|
|
33
|
+
(0x26BD, 0x26BE),
|
|
34
|
+
(0x26C4, 0x26C5),
|
|
35
|
+
(0x26CE, 0x26CE),
|
|
36
|
+
(0x26D4, 0x26D4),
|
|
37
|
+
(0x26EA, 0x26EA),
|
|
38
|
+
(0x26F2, 0x26F3),
|
|
39
|
+
(0x26F5, 0x26F5),
|
|
40
|
+
(0x26FA, 0x26FA),
|
|
41
|
+
(0x26FD, 0x26FD),
|
|
42
|
+
(0x2702, 0x2702),
|
|
43
|
+
(0x2705, 0x2705),
|
|
44
|
+
(0x2708, 0x270D),
|
|
45
|
+
(0x270F, 0x270F),
|
|
46
|
+
(0x2712, 0x2712),
|
|
47
|
+
(0x2714, 0x2714),
|
|
48
|
+
(0x2716, 0x2716),
|
|
49
|
+
(0x271D, 0x271D),
|
|
50
|
+
(0x2721, 0x2721),
|
|
51
|
+
(0x2728, 0x2728),
|
|
52
|
+
(0x2733, 0x2734),
|
|
53
|
+
(0x2744, 0x2744),
|
|
54
|
+
(0x2747, 0x2747),
|
|
55
|
+
(0x274C, 0x274C),
|
|
56
|
+
(0x274E, 0x274E),
|
|
57
|
+
(0x2753, 0x2755),
|
|
58
|
+
(0x2757, 0x2757),
|
|
59
|
+
(0x2763, 0x2764),
|
|
60
|
+
(0x2795, 0x2797),
|
|
61
|
+
(0x27A1, 0x27A1),
|
|
62
|
+
(0x27B0, 0x27B0),
|
|
63
|
+
(0x27BF, 0x27BF),
|
|
64
|
+
(0x2934, 0x2935),
|
|
65
|
+
(0x2B05, 0x2B07),
|
|
66
|
+
(0x2B1B, 0x2B1C),
|
|
67
|
+
(0x2B50, 0x2B50),
|
|
68
|
+
(0x2B55, 0x2B55),
|
|
69
|
+
(0x3030, 0x3030),
|
|
70
|
+
(0x303D, 0x303D),
|
|
71
|
+
(0x3297, 0x3297),
|
|
72
|
+
(0x3299, 0x3299),
|
|
73
|
+
(0xE0020, 0xE007F),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_emoji_char(ch: str) -> bool:
|
|
78
|
+
"""Whether one character falls in an emoji range."""
|
|
79
|
+
if not ch:
|
|
80
|
+
return False
|
|
81
|
+
code_point = ord(ch)
|
|
82
|
+
return any(start <= code_point <= end for start, end in EMOJI_RANGES)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["EMOJI_RANGES", "is_emoji_char"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Forced splitting of long sentences and inline Markdown span repair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def has_unclosed_inline_span(text: str) -> bool:
|
|
7
|
+
"""Whether ``**bold**`` / `` `code` `` pairs were split apart.
|
|
8
|
+
|
|
9
|
+
Only unescaped ``**`` and backticks count; escaped characters are skipped.
|
|
10
|
+
An odd count means an unclosed pair.
|
|
11
|
+
"""
|
|
12
|
+
count = 0
|
|
13
|
+
index = 0
|
|
14
|
+
length = len(text)
|
|
15
|
+
while index < length:
|
|
16
|
+
char = text[index]
|
|
17
|
+
if char == "\\":
|
|
18
|
+
index += 2
|
|
19
|
+
continue
|
|
20
|
+
if text.startswith("**", index):
|
|
21
|
+
count += 1
|
|
22
|
+
index += 2
|
|
23
|
+
continue
|
|
24
|
+
if char == "`":
|
|
25
|
+
count += 1
|
|
26
|
+
index += 1
|
|
27
|
+
continue
|
|
28
|
+
index += 1
|
|
29
|
+
return count % 2 == 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def repair_broken_inline_spans(chunks: list[str]) -> list[str]:
|
|
33
|
+
"""Merge following chunks until every inline span is balanced.
|
|
34
|
+
|
|
35
|
+
Joining without a separator is intentional: the splitter removed the
|
|
36
|
+
sentence punctuation at the break, so direct concatenation restores a
|
|
37
|
+
valid ``**...**`` pair.
|
|
38
|
+
"""
|
|
39
|
+
if len(chunks) < 2:
|
|
40
|
+
return chunks
|
|
41
|
+
result: list[str] = []
|
|
42
|
+
buffer: str | None = None
|
|
43
|
+
for chunk in chunks:
|
|
44
|
+
candidate = chunk if buffer is None else buffer + chunk
|
|
45
|
+
if has_unclosed_inline_span(candidate):
|
|
46
|
+
buffer = candidate
|
|
47
|
+
else:
|
|
48
|
+
result.append(candidate)
|
|
49
|
+
buffer = None
|
|
50
|
+
if buffer is not None:
|
|
51
|
+
result.append(buffer)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def force_split_long_sentence(s: str, max_len: int = 100, max_chunks: int = 7) -> list[str]:
|
|
56
|
+
"""Split an over-long sentence near punctuation, bounded by ``max_chunks``."""
|
|
57
|
+
if len(s) <= max_len:
|
|
58
|
+
return [s]
|
|
59
|
+
|
|
60
|
+
result: list[str] = []
|
|
61
|
+
target_chunk_size = max_len
|
|
62
|
+
position = 0
|
|
63
|
+
while position < len(s):
|
|
64
|
+
remaining_length = len(s) - position
|
|
65
|
+
if remaining_length <= max_len:
|
|
66
|
+
if remaining_length > 0:
|
|
67
|
+
result.append(s[position:].strip())
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
target_end = min(position + target_chunk_size, len(s))
|
|
71
|
+
split_char_found = False
|
|
72
|
+
for index in range(target_end - 1, max(position + max_len // 2, position), -1):
|
|
73
|
+
if s[index] in [",", ",", " ", "\u3000", ".", "。", ";", ";", "!", "!", "?", "?"]:
|
|
74
|
+
chunk = s[position : index + 1].strip()
|
|
75
|
+
if chunk:
|
|
76
|
+
result.append(chunk)
|
|
77
|
+
position = index + 1
|
|
78
|
+
split_char_found = True
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if not split_char_found:
|
|
82
|
+
for index in range(target_end, min(target_end + 20, len(s))):
|
|
83
|
+
if s[index] in [",", ",", " ", "\u3000", ".", "。", ";", ";", "!", "!", "?", "?"]:
|
|
84
|
+
chunk = s[position : index + 1].strip()
|
|
85
|
+
if chunk:
|
|
86
|
+
result.append(chunk)
|
|
87
|
+
position = index + 1
|
|
88
|
+
split_char_found = True
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if not split_char_found:
|
|
92
|
+
chunk = s[position:target_end].strip()
|
|
93
|
+
if chunk:
|
|
94
|
+
result.append(chunk)
|
|
95
|
+
position = target_end
|
|
96
|
+
|
|
97
|
+
if len(result) > max_chunks:
|
|
98
|
+
avg_chunk_size = len(s) // max_chunks
|
|
99
|
+
new_result: list[str] = []
|
|
100
|
+
current_chunk = ""
|
|
101
|
+
for chunk in result:
|
|
102
|
+
if len(current_chunk) + len(chunk) <= avg_chunk_size + 20:
|
|
103
|
+
current_chunk += chunk
|
|
104
|
+
else:
|
|
105
|
+
if current_chunk:
|
|
106
|
+
new_result.append(current_chunk.strip())
|
|
107
|
+
current_chunk = chunk
|
|
108
|
+
if current_chunk:
|
|
109
|
+
new_result.append(current_chunk.strip())
|
|
110
|
+
|
|
111
|
+
if len(new_result) <= max_chunks:
|
|
112
|
+
result = new_result
|
|
113
|
+
else:
|
|
114
|
+
result = []
|
|
115
|
+
step = len(s) // max_chunks
|
|
116
|
+
for index in range(0, len(s), step):
|
|
117
|
+
chunk = s[index : index + step].strip()
|
|
118
|
+
if chunk:
|
|
119
|
+
result.append(chunk)
|
|
120
|
+
|
|
121
|
+
return result if result else [s]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"has_unclosed_inline_span",
|
|
126
|
+
"repair_broken_inline_spans",
|
|
127
|
+
"force_split_long_sentence",
|
|
128
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Continuation detection and chunk merging.
|
|
2
|
+
|
|
3
|
+
With ``jieba`` installed, the first non-punctuation token's POS tag decides
|
|
4
|
+
whether a chunk begins with a continuation (conjunction ``c`` or an adverb
|
|
5
|
+
stem like 再/又/还/更). Without ``jieba`` the library falls back to a compact
|
|
6
|
+
conjunction prefix list, so the package stays dependency-free by default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .rules import (
|
|
15
|
+
CONTINUATION_ADVERBS,
|
|
16
|
+
CONTINUATION_ENDINGS,
|
|
17
|
+
EXPLICIT_SPACE_BOUNDARY_ENDINGS,
|
|
18
|
+
PUNCTUATION_FOR_SPACE_GUARD,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try: # optional dependency
|
|
22
|
+
import jieba
|
|
23
|
+
import jieba.posseg as pseg
|
|
24
|
+
|
|
25
|
+
HAS_JIEBA = True
|
|
26
|
+
jieba.setLogLevel(logging.INFO)
|
|
27
|
+
except ImportError: # pragma: no cover - exercised only without the extra
|
|
28
|
+
pseg = None
|
|
29
|
+
HAS_JIEBA = False
|
|
30
|
+
|
|
31
|
+
# Fallback prefixes used when jieba is unavailable. Kept intentionally small:
|
|
32
|
+
# they cover the conjunctions/continuations that actually appear at bubble
|
|
33
|
+
# starts, without pretending to be a full POS tagger.
|
|
34
|
+
_CONTINUATION_PREFIXES = (
|
|
35
|
+
"然后",
|
|
36
|
+
"而且",
|
|
37
|
+
"并且",
|
|
38
|
+
"但是",
|
|
39
|
+
"不过",
|
|
40
|
+
"所以",
|
|
41
|
+
"因为",
|
|
42
|
+
"虽然",
|
|
43
|
+
"如果",
|
|
44
|
+
"要是",
|
|
45
|
+
"不然",
|
|
46
|
+
"否则",
|
|
47
|
+
"于是",
|
|
48
|
+
"接着",
|
|
49
|
+
"随后",
|
|
50
|
+
"同时",
|
|
51
|
+
"另外",
|
|
52
|
+
"再说",
|
|
53
|
+
"还有",
|
|
54
|
+
"反正",
|
|
55
|
+
"毕竟",
|
|
56
|
+
"其实",
|
|
57
|
+
"只是",
|
|
58
|
+
"就是",
|
|
59
|
+
) + tuple(CONTINUATION_ADVERBS)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_PUNCTUATION_CHARS = "".join(PUNCTUATION_FOR_SPACE_GUARD)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_continuation_start(text: str) -> bool:
|
|
66
|
+
"""Whether the text starts with a continuation word.
|
|
67
|
+
|
|
68
|
+
Leading punctuation is skipped. With jieba, the first token's POS decides;
|
|
69
|
+
otherwise a prefix list is used.
|
|
70
|
+
"""
|
|
71
|
+
if not text or not text.strip():
|
|
72
|
+
return False
|
|
73
|
+
stripped = text.strip()[:30]
|
|
74
|
+
|
|
75
|
+
if HAS_JIEBA and pseg is not None:
|
|
76
|
+
for word, flag in pseg.cut(stripped):
|
|
77
|
+
if not word.strip():
|
|
78
|
+
continue
|
|
79
|
+
if flag in ("x", "w"):
|
|
80
|
+
continue
|
|
81
|
+
if flag == "c":
|
|
82
|
+
return True
|
|
83
|
+
return any(word.startswith(adverb) for adverb in CONTINUATION_ADVERBS)
|
|
84
|
+
|
|
85
|
+
cleaned = stripped.lstrip(_PUNCTUATION_CHARS)
|
|
86
|
+
return any(cleaned.startswith(prefix) for prefix in _CONTINUATION_PREFIXES)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge_chunks_to_limit(chunks: list[str], max_chunks: int = 7) -> list[str]:
|
|
90
|
+
"""Merge the shortest adjacent pairs until at most ``max_chunks`` remain."""
|
|
91
|
+
if len(chunks) <= max_chunks:
|
|
92
|
+
return chunks
|
|
93
|
+
|
|
94
|
+
result = list(chunks)
|
|
95
|
+
for _ in range(len(result) - max_chunks):
|
|
96
|
+
min_len = float("inf")
|
|
97
|
+
merge_index = -1
|
|
98
|
+
for index in range(len(result) - 1):
|
|
99
|
+
combined_len = len(result[index]) + len(result[index + 1])
|
|
100
|
+
if combined_len < min_len:
|
|
101
|
+
min_len = combined_len
|
|
102
|
+
merge_index = index
|
|
103
|
+
if merge_index < 0:
|
|
104
|
+
break
|
|
105
|
+
result[merge_index] = result[merge_index] + result[merge_index + 1]
|
|
106
|
+
result.pop(merge_index + 1)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def merge_continuation_chunks(
|
|
111
|
+
chunks: list[str], max_merge_len: int = 300, min_split_len: int = 40
|
|
112
|
+
) -> list[str]:
|
|
113
|
+
"""Merge unnatural breaks caused by continuation words or open endings."""
|
|
114
|
+
if len(chunks) <= 1:
|
|
115
|
+
return chunks
|
|
116
|
+
|
|
117
|
+
result = [chunks[0]]
|
|
118
|
+
for index in range(1, len(chunks)):
|
|
119
|
+
prev = result[-1]
|
|
120
|
+
curr = chunks[index]
|
|
121
|
+
curr_stripped = curr.strip()
|
|
122
|
+
prev_stripped = prev.strip()
|
|
123
|
+
|
|
124
|
+
should_merge = False
|
|
125
|
+
if is_continuation_start(curr_stripped):
|
|
126
|
+
prev_ends_with_sentence_punct = bool(
|
|
127
|
+
prev_stripped
|
|
128
|
+
and any(
|
|
129
|
+
prev_stripped.endswith(ending)
|
|
130
|
+
for ending in EXPLICIT_SPACE_BOUNDARY_ENDINGS
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
if not prev_ends_with_sentence_punct and len(prev_stripped) < min_split_len:
|
|
134
|
+
should_merge = True
|
|
135
|
+
|
|
136
|
+
if prev_stripped and any(
|
|
137
|
+
prev_stripped.endswith(ending) for ending in CONTINUATION_ENDINGS
|
|
138
|
+
):
|
|
139
|
+
should_merge = True
|
|
140
|
+
|
|
141
|
+
if should_merge and len(prev) + len(curr) <= max_merge_len:
|
|
142
|
+
result[-1] = prev + curr
|
|
143
|
+
else:
|
|
144
|
+
result.append(curr)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# A space between a CJK character and a Latin/digit/Greek/math character is
|
|
149
|
+
# typography, not a manual bubble separator.
|
|
150
|
+
_CJK_ASCII_SPACE_RE = re.compile(
|
|
151
|
+
r"(?<=[\u4e00-\u9fff])[ \t]+"
|
|
152
|
+
r"(?=[0-9A-Za-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff"
|
|
153
|
+
r"\u1e00-\u1eff\u2070-\u209f\u2150-\u218f\u2200-\u22ff])"
|
|
154
|
+
r"|(?<=[0-9A-Za-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff"
|
|
155
|
+
r"\u1e00-\u1eff\u2070-\u209f\u2150-\u218f\u2200-\u22ff])[ \t]+"
|
|
156
|
+
r"(?=[\u4e00-\u9fff])"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def glue_cjk_ascii_spaces(text: str) -> str:
|
|
161
|
+
"""Remove typographic spaces between CJK and Latin/number/math characters."""
|
|
162
|
+
return _CJK_ASCII_SPACE_RE.sub("", str(text or ""))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def looks_like_manual_space_split(text: str) -> bool:
|
|
166
|
+
"""Whether punctuation-free text looks like manually space-separated bubbles."""
|
|
167
|
+
normalized = str(text or "").strip()
|
|
168
|
+
if not normalized or "\n" in normalized:
|
|
169
|
+
return False
|
|
170
|
+
if any(char in PUNCTUATION_FOR_SPACE_GUARD for char in normalized):
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
glued = glue_cjk_ascii_spaces(normalized)
|
|
174
|
+
parts = [part.strip() for part in re.split(r"\s+", glued) if part.strip()]
|
|
175
|
+
if len(parts) < 3:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
cjk_chars = re.findall(r"[\u4e00-\u9fff]", glued)
|
|
179
|
+
if len(cjk_chars) < max(6, len(glued.replace(" ", "")) // 3):
|
|
180
|
+
return False
|
|
181
|
+
if any(len(part) > 12 for part in parts):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
avg_len = sum(len(part) for part in parts) / max(1, len(parts))
|
|
185
|
+
return avg_len <= 8
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def merge_space_chunks_to_limit(chunks: list[str], max_chunks: int = 6) -> list[str]:
|
|
189
|
+
"""Re-merge space-separated chunks until the bubble count fits."""
|
|
190
|
+
cleaned = [str(chunk or "").strip() for chunk in chunks if str(chunk or "").strip()]
|
|
191
|
+
if len(cleaned) <= max_chunks:
|
|
192
|
+
return cleaned
|
|
193
|
+
|
|
194
|
+
result = list(cleaned)
|
|
195
|
+
while len(result) > max_chunks:
|
|
196
|
+
min_len = float("inf")
|
|
197
|
+
merge_index = -1
|
|
198
|
+
for index in range(len(result) - 1):
|
|
199
|
+
combined_len = len(result[index]) + len(result[index + 1])
|
|
200
|
+
if combined_len < min_len:
|
|
201
|
+
min_len = combined_len
|
|
202
|
+
merge_index = index
|
|
203
|
+
if merge_index < 0:
|
|
204
|
+
break
|
|
205
|
+
result[merge_index] = f"{result[merge_index]} {result[merge_index + 1]}".strip()
|
|
206
|
+
result.pop(merge_index + 1)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = [
|
|
211
|
+
"HAS_JIEBA",
|
|
212
|
+
"is_continuation_start",
|
|
213
|
+
"merge_chunks_to_limit",
|
|
214
|
+
"merge_continuation_chunks",
|
|
215
|
+
"glue_cjk_ascii_spaces",
|
|
216
|
+
"looks_like_manual_space_split",
|
|
217
|
+
"merge_space_chunks_to_limit",
|
|
218
|
+
]
|