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.
@@ -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
+ ]