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,95 @@
1
+ """Numbered list detection: split ``1. xxx 2. xxx`` into separate lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .rules import (
6
+ _NUMBERED_ITEM_ALLOWED_PREV,
7
+ _NUMBERED_ITEM_RE,
8
+ _NUMBERED_LIST_LEAD_MERGE_ENDINGS,
9
+ _NUMBERED_LIST_LEAD_MERGE_LIMIT,
10
+ )
11
+
12
+
13
+ def is_numbered_item_start(s: str, idx: int) -> bool:
14
+ """Whether the number at ``idx`` can start a numbered list item."""
15
+ if idx <= 0:
16
+ return True
17
+ prev = s[idx - 1]
18
+ if prev.isspace():
19
+ return True
20
+ if prev.isdigit() or prev == ".":
21
+ return False
22
+ return prev in _NUMBERED_ITEM_ALLOWED_PREV
23
+
24
+
25
+ def find_numbered_list_starts(s: str) -> list[int]:
26
+ """Offsets of items in the longest strictly increasing numbering run.
27
+
28
+ At least two items are required, so decimals, versions and dates are not
29
+ mistaken for lists.
30
+ """
31
+ candidates: list[tuple[int, int]] = []
32
+ for match in _NUMBERED_ITEM_RE.finditer(s):
33
+ start = match.start()
34
+ if not is_numbered_item_start(s, start):
35
+ continue
36
+ look = match.end()
37
+ while look < len(s) and s[look].isspace():
38
+ look += 1
39
+ next_char = s[look] if look < len(s) else ""
40
+ if not (next_char.isalpha() or "\u4e00" <= next_char <= "\u9fff"):
41
+ continue
42
+ candidates.append((start, int(match.group("num"))))
43
+
44
+ if len(candidates) < 2:
45
+ return []
46
+
47
+ best: list[tuple[int, int]] = []
48
+ index = 0
49
+ while index < len(candidates):
50
+ sequence = [candidates[index]]
51
+ cursor = index + 1
52
+ while cursor < len(candidates) and candidates[cursor][1] > sequence[-1][1]:
53
+ sequence.append(candidates[cursor])
54
+ cursor += 1
55
+ if len(sequence) > len(best):
56
+ best = sequence
57
+ index = cursor
58
+
59
+ return [start for start, _ in best] if len(best) >= 2 else []
60
+
61
+
62
+ def normalize_numbered_list(s: str) -> str:
63
+ """Turn an inline numbered list into newline-separated items.
64
+
65
+ The downstream newline splitter then treats each item as its own bubble,
66
+ so a bubble cannot end mid-item and resume at ``2.``.
67
+ """
68
+ starts = find_numbered_list_starts(s)
69
+ if not starts:
70
+ return s
71
+
72
+ segments: list[str] = []
73
+ for index, start in enumerate(starts):
74
+ end = starts[index + 1] if index + 1 < len(starts) else len(s)
75
+ segments.append(s[start:end])
76
+
77
+ lead = s[: starts[0]]
78
+ lead_stripped = lead.strip()
79
+ lead_has_newline = lead.rstrip(" \t").endswith("\n")
80
+ merge_lead = bool(lead_stripped) and not lead_has_newline and (
81
+ len(lead_stripped) <= _NUMBERED_LIST_LEAD_MERGE_LIMIT
82
+ or lead_stripped.endswith(_NUMBERED_LIST_LEAD_MERGE_ENDINGS)
83
+ )
84
+
85
+ if lead_stripped:
86
+ if merge_lead:
87
+ gap = " " if lead[-1:].isspace() else ""
88
+ segments[0] = f"{lead_stripped}{gap}{segments[0]}"
89
+ else:
90
+ segments.insert(0, lead_stripped)
91
+
92
+ return "\n".join(segment.strip() for segment in segments if segment.strip())
93
+
94
+
95
+ __all__ = ["is_numbered_item_start", "find_numbered_list_starts", "normalize_numbered_list"]
File without changes
@@ -0,0 +1,65 @@
1
+ """Rule constants shared by the segmenter (single source of truth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Timestamps the model copies from history prefixes, e.g. ``[05-22 01:45]``,
8
+ # ``[今天 20:00]`` or ``[3天前 08:00]``. Stripped everywhere, not just at line
9
+ # starts, because the model can emit one mid-reply.
10
+ AI_TIMESTAMP_PATTERN_SOURCE = (
11
+ r"\[(?:(?:\d{2,4}(?:-\d{2}){1,2}|今天|昨天|\d+天前)\s+)?"
12
+ r"\d{2}:\d{2}(?::\d{2})?(?:\s*\([^)]+\))?\]\s*"
13
+ )
14
+ AI_TIMESTAMP_PATTERN = re.compile(AI_TIMESTAMP_PATTERN_SOURCE)
15
+
16
+ # Trailing punctuation removed at the end of a bubble. The ellipsis ``…`` is
17
+ # deliberately absent: it is part of the emotion, not noise.
18
+ TRAILING_PUNCT_CHARS = "。.,,.、;;"
19
+
20
+ # Markdown block starts: fences, headings, list items, quotes.
21
+ _MARKDOWN_BLOCK_START_RE = re.compile(r"(?m)^\s{0,3}(?:```|~~~|#{1,6}\s|[-*+]\s|>)")
22
+ # GFM table rows (whole line wrapped in pipes) must stay grouped.
23
+ _MARKDOWN_TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$")
24
+ # Unescaped ``$$`` marks a LaTeX block; keep the whole line.
25
+ _LATEX_MARKER_RE = re.compile(r"(?<!\\)\$\$")
26
+
27
+ CONTINUATION_ADVERBS = frozenset({"再", "又", "还", "更"})
28
+ CONTINUATION_ENDINGS = (":", ":", "——", "—")
29
+ HARD_BUBBLE_BOUNDARY_ENDINGS = ("。", ".", "!", "!", "?", "?", "…")
30
+ EXPLICIT_SPACE_BOUNDARY_ENDINGS = HARD_BUBBLE_BOUNDARY_ENDINGS + (
31
+ ",",
32
+ ",",
33
+ ";",
34
+ ";",
35
+ )
36
+ PUNCTUATION_FOR_SPACE_GUARD = frozenset(
37
+ "。.!!??,,;;::、…~~()()[]【】{}<>《》\"'“”‘’"
38
+ )
39
+
40
+ # Short prefixes before an ellipsis merge with the following sentence instead
41
+ # of becoming their own bubble (``就是……戳废了`` stays one bubble).
42
+ ELLIPSIS_MERGE_PREFIX_LIMIT = 6
43
+
44
+ # Numbered list item: ``1. xxx`` / ``2、xxx`` / ``3) xxx``. A number followed
45
+ # by a separator only counts when actual content follows, so decimals,
46
+ # versions and dates are not mistaken for list items.
47
+ _NUMBERED_ITEM_RE = re.compile(r"(?P<num>\d{1,2})(?P<sep>[..、))])")
48
+ _NUMBERED_ITEM_ALLOWED_PREV = frozenset(
49
+ "。.!!??,,;;::、…~~()()[]【】{}<>《》\"'“”‘’——-—*·/|"
50
+ )
51
+ _NUMBERED_LIST_LEAD_MERGE_LIMIT = 24
52
+ _NUMBERED_LIST_LEAD_MERGE_ENDINGS = (":", ":", "——", "—", "-", "~", "~")
53
+
54
+
55
+ __all__ = [
56
+ "AI_TIMESTAMP_PATTERN",
57
+ "AI_TIMESTAMP_PATTERN_SOURCE",
58
+ "TRAILING_PUNCT_CHARS",
59
+ "CONTINUATION_ADVERBS",
60
+ "CONTINUATION_ENDINGS",
61
+ "HARD_BUBBLE_BOUNDARY_ENDINGS",
62
+ "EXPLICIT_SPACE_BOUNDARY_ENDINGS",
63
+ "PUNCTUATION_FOR_SPACE_GUARD",
64
+ "ELLIPSIS_MERGE_PREFIX_LIMIT",
65
+ ]
@@ -0,0 +1,445 @@
1
+ """The main bubble splitter.
2
+
3
+ Rules (long-tuned against real chat behavior):
4
+
5
+ 1. Prefer sentence punctuation (。?!) once the current chunk is long enough.
6
+ 2. Fall back to commas/semicolons, with a configurable probability.
7
+ 3. Force-break over-long text near a punctuation mark.
8
+ 4. Keep bracketed spans, quotes and numbered lists intact.
9
+ 5. Merge unnatural breaks caused by continuation words or open endings.
10
+
11
+ ``rng`` is injectable so the comma-split probability can be made deterministic
12
+ in tests. The default uses the module-level RNG, matching the runtime.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ import re
19
+ from typing import Callable, Optional
20
+
21
+ from .emoji import is_emoji_char
22
+ from .force import force_split_long_sentence, repair_broken_inline_spans
23
+ from .merge import (
24
+ glue_cjk_ascii_spaces,
25
+ looks_like_manual_space_split,
26
+ merge_chunks_to_limit,
27
+ merge_continuation_chunks,
28
+ merge_space_chunks_to_limit,
29
+ )
30
+ from .numbered import normalize_numbered_list
31
+ from .rules import (
32
+ ELLIPSIS_MERGE_PREFIX_LIMIT,
33
+ EXPLICIT_SPACE_BOUNDARY_ENDINGS,
34
+ HARD_BUBBLE_BOUNDARY_ENDINGS,
35
+ )
36
+
37
+ Preprocess = Callable[[str], str]
38
+
39
+
40
+ def split_chat_message(
41
+ text: str,
42
+ max_len: int = 150,
43
+ comma_split_prob: float = 0.2,
44
+ min_split_len: int = 40,
45
+ preprocess: Optional[Preprocess] = None,
46
+ pass_through_markers: tuple[str, ...] = (),
47
+ rng: Optional[random.Random] = None,
48
+ ) -> list[str]:
49
+ """Split one chat message into bubbles.
50
+
51
+ Args:
52
+ text: Input text.
53
+ max_len: Maximum bubble length.
54
+ comma_split_prob: Probability of splitting at a comma when over the
55
+ soft limit.
56
+ min_split_len: Minimum accumulated length before sentence punctuation
57
+ is allowed to split.
58
+ preprocess: Optional channel-specific preprocessing (e.g. Markdown
59
+ stripping). Runs before splitting.
60
+ pass_through_markers: If any marker is present, return the text whole.
61
+ rng: Optional seeded RNG for the comma-split probability.
62
+ """
63
+ s = str(text or "")
64
+ if not s:
65
+ return []
66
+ for marker in pass_through_markers:
67
+ if marker and marker in s:
68
+ return [s]
69
+
70
+ if preprocess is not None:
71
+ s = preprocess(s)
72
+ s = str(s or "")
73
+ if not s:
74
+ return []
75
+
76
+ # Normalize explicit newline escapes: /n, \n and double-escaped \\n.
77
+ s = re.sub(r"(?:\\+|[//])[nN]", "\n", s)
78
+
79
+ if looks_like_manual_space_split(s):
80
+ glued = glue_cjk_ascii_spaces(s)
81
+ parts = [part.strip() for part in re.split(r"\s+", glued) if part.strip()]
82
+ return merge_space_chunks_to_limit(parts, max_chunks=6)
83
+
84
+ s = re.sub(r"。\.{3,}", "......", s)
85
+ s = re.sub(r"。…+", "……", s)
86
+
87
+ for _ in range(3):
88
+ merged = re.sub(
89
+ r"([((][^))\n]{1,120})\n([^))\n]{0,120}[))])",
90
+ r"\1 \2",
91
+ s,
92
+ )
93
+ if merged == s:
94
+ break
95
+ s = merged
96
+
97
+ # Remove newlines inside western and Chinese corner quotes.
98
+ s = re.sub(r"([\u201c\u2018\u300c\u300e])\s*\n", r"\1", s)
99
+ s = re.sub(r"\n\s*([\u201d\u2019\u300d\u300f])", r"\1", s)
100
+
101
+ if s.startswith("[") and "]" in s:
102
+ first_close_index = s.find("]")
103
+ if first_close_index == len(s) - 1:
104
+ return [s]
105
+ if len(s) - first_close_index <= 10:
106
+ return [s]
107
+
108
+ s = normalize_numbered_list(s)
109
+
110
+ max_len = max(20, int(max_len or 150))
111
+ comma_split_prob = max(0.0, min(1.0, float(comma_split_prob)))
112
+ min_split_len = max(10, int(min_split_len or 40))
113
+ if "\n" in s:
114
+ lines = s.split("\n")
115
+ merged_chunks: list[str] = []
116
+ for line in lines:
117
+ line = line.strip()
118
+ if not line:
119
+ continue
120
+ merged_chunks.extend(
121
+ split_chat_message(
122
+ line,
123
+ max_len,
124
+ comma_split_prob,
125
+ min_split_len,
126
+ preprocess=preprocess,
127
+ pass_through_markers=pass_through_markers,
128
+ rng=rng,
129
+ )
130
+ )
131
+ # Explicit newlines are user intent: no continuation merge, no cap.
132
+ return merged_chunks
133
+
134
+ result: list[str] = []
135
+ current = ""
136
+ i = 0
137
+ stack: list[str] = []
138
+ closing_to_opening = {
139
+ ")": "(",
140
+ ")": "(",
141
+ "]": "[",
142
+ "}": "{",
143
+ "】": "【",
144
+ "\u201d": "\u201c",
145
+ "\u2019": "\u2018",
146
+ '"': '"',
147
+ "'": "'",
148
+ }
149
+ while i < len(s):
150
+ char = s[i]
151
+ prev_char = s[i - 1] if i > 0 else ""
152
+ next_char = s[i + 1] if i + 1 < len(s) else ""
153
+ look_ahead = i + 1
154
+ saw_space_after_punct = False
155
+ while look_ahead < len(s) and s[look_ahead].isspace():
156
+ saw_space_after_punct = True
157
+ look_ahead += 1
158
+ next_visible_char = s[look_ahead] if look_ahead < len(s) else ""
159
+ explicit_space_boundary = saw_space_after_punct and bool(next_visible_char)
160
+
161
+ if char == '"':
162
+ if stack and stack[-1] == char:
163
+ stack.pop()
164
+ else:
165
+ stack.append(char)
166
+ elif char == "'":
167
+ is_contraction = (
168
+ i > 0
169
+ and i < len(s) - 1
170
+ and s[i - 1].isalpha()
171
+ and s[i + 1].isalpha()
172
+ )
173
+ if not is_contraction:
174
+ if stack and stack[-1] == char:
175
+ stack.pop()
176
+ else:
177
+ stack.append(char)
178
+ elif char in ["(", "(", "[", "{", "【", "\u201c", "\u2018"]:
179
+ stack.append(char)
180
+ elif char in closing_to_opening:
181
+ opening = closing_to_opening[char]
182
+ if stack and stack[-1] == opening:
183
+ stack.pop()
184
+
185
+ current += char
186
+
187
+ # After a closing parenthesis followed by an ellipsis and then an
188
+ # ordinary sentence, split at the parenthesis.
189
+ if char in [")", ")"] and not stack:
190
+ look_ahead = i + 1
191
+ while look_ahead < len(s) and s[look_ahead].isspace() and s[look_ahead] != "\n":
192
+ look_ahead += 1
193
+ if look_ahead < len(s) and s[look_ahead] == "…":
194
+ while look_ahead < len(s) and s[look_ahead] == "…":
195
+ look_ahead += 1
196
+ after_ellipsis = look_ahead
197
+ while (
198
+ after_ellipsis < len(s)
199
+ and s[after_ellipsis].isspace()
200
+ and s[after_ellipsis] != "\n"
201
+ ):
202
+ after_ellipsis += 1
203
+ next_char = s[after_ellipsis] if after_ellipsis < len(s) else ""
204
+ exclamation_words = ("哈", "啊", "哇", "哎", "唉", "唔", "嗯", "哼", "咦", "嘿", "噢")
205
+ if (
206
+ next_char
207
+ and next_char not in HARD_BUBBLE_BOUNDARY_ENDINGS
208
+ and next_char not in exclamation_words
209
+ and next_char != "\n"
210
+ ):
211
+ if current.strip() and len(result) < 2:
212
+ result.append(current.strip())
213
+ current = ""
214
+
215
+ if char == ".":
216
+ dot_count = 1
217
+ while i + dot_count < len(s) and s[i + dot_count] == ".":
218
+ dot_count += 1
219
+ current += "." * (dot_count - 1)
220
+ if dot_count >= 3:
221
+ look_ahead = i + dot_count
222
+ while look_ahead < len(s) and s[look_ahead].isspace() and s[look_ahead] != "\n":
223
+ look_ahead += 1
224
+ next_char = s[look_ahead] if look_ahead < len(s) else ""
225
+
226
+ exclamation_words_en = (
227
+ "哈", "啊", "哇", "哎", "唉", "唔", "嗯", "哼", "咦", "嘿", "噢",
228
+ "ha", "Ha", "HA", "ah", "Ah", "AH", "oh", "Oh", "OH",
229
+ "wow", "Wow", "WOW",
230
+ )
231
+ is_exclamation_pattern = False
232
+ if next_char and next_char in exclamation_words_en:
233
+ after_word = look_ahead + 1
234
+ while after_word < len(s) and s[after_word] not in HARD_BUBBLE_BOUNDARY_ENDINGS:
235
+ if s[after_word].isspace() and s[after_word] != "\n":
236
+ after_word += 1
237
+ continue
238
+ break
239
+ if after_word < len(s) and s[after_word] in HARD_BUBBLE_BOUNDARY_ENDINGS:
240
+ is_exclamation_pattern = True
241
+
242
+ should_split = (
243
+ not stack
244
+ and len(result) < 2
245
+ and not is_exclamation_pattern
246
+ and (
247
+ next_char == ""
248
+ or next_char == "\n"
249
+ or (look_ahead > i + dot_count and s[look_ahead - 1] == " ")
250
+ or (
251
+ next_char not in HARD_BUBBLE_BOUNDARY_ENDINGS
252
+ and next_char not in exclamation_words_en
253
+ )
254
+ )
255
+ )
256
+ if should_split:
257
+ if current.strip():
258
+ result.append(current.strip())
259
+ current = ""
260
+ i += dot_count
261
+ continue
262
+
263
+ if char == "…":
264
+ ellipsis_count = 1
265
+ while i + ellipsis_count < len(s) and s[i + ellipsis_count] == "…":
266
+ ellipsis_count += 1
267
+ current += "…" * (ellipsis_count - 1)
268
+ look_ahead = i + ellipsis_count
269
+ while look_ahead < len(s) and s[look_ahead].isspace() and s[look_ahead] != "\n":
270
+ look_ahead += 1
271
+ next_char = s[look_ahead] if look_ahead < len(s) else ""
272
+
273
+ exclamation_words = ("哈", "啊", "哇", "哎", "唉", "唔", "嗯", "哼", "咦", "嘿", "噢")
274
+ is_exclamation_pattern = False
275
+ if next_char and next_char in exclamation_words:
276
+ after_word = look_ahead + 1
277
+ while after_word < len(s) and s[after_word] not in HARD_BUBBLE_BOUNDARY_ENDINGS:
278
+ if s[after_word].isspace() and s[after_word] != "\n":
279
+ after_word += 1
280
+ continue
281
+ break
282
+ if after_word < len(s) and s[after_word] in HARD_BUBBLE_BOUNDARY_ENDINGS:
283
+ is_exclamation_pattern = True
284
+
285
+ ellipsis_core = current.rstrip("…").strip()
286
+ is_short_prefix = bool(ellipsis_core) and len(ellipsis_core) <= ELLIPSIS_MERGE_PREFIX_LIMIT
287
+ is_normal_sentence_after = (
288
+ next_char
289
+ and next_char not in HARD_BUBBLE_BOUNDARY_ENDINGS
290
+ and next_char not in exclamation_words
291
+ and next_char != "\n"
292
+ )
293
+ has_explicit_space_after = look_ahead > i + ellipsis_count and s[look_ahead - 1] == " "
294
+
295
+ should_split = (
296
+ not stack
297
+ and len(result) < 2
298
+ and not is_exclamation_pattern
299
+ and not (
300
+ is_short_prefix
301
+ and is_normal_sentence_after
302
+ and not has_explicit_space_after
303
+ )
304
+ and (
305
+ next_char == ""
306
+ or next_char == "\n"
307
+ or has_explicit_space_after
308
+ or (
309
+ next_char not in HARD_BUBBLE_BOUNDARY_ENDINGS
310
+ and next_char not in exclamation_words
311
+ )
312
+ )
313
+ )
314
+ if should_split:
315
+ if current.strip():
316
+ result.append(current.strip())
317
+ current = ""
318
+ i += ellipsis_count
319
+ continue
320
+
321
+ if (not stack) and char in ["。", "!", "!", "?", "?", ",", ",", ".", ";", ";"]:
322
+ if char == "." and prev_char.isdigit() and next_char.isdigit():
323
+ i += 1
324
+ continue
325
+ if char == ".":
326
+ numbered_prefix = current.strip()
327
+ look_ahead = i + 1
328
+ while look_ahead < len(s) and s[look_ahead].isspace():
329
+ look_ahead += 1
330
+ if re.fullmatch(r"\d{1,3}\.", numbered_prefix):
331
+ if look_ahead < len(s) and (
332
+ s[look_ahead].isalpha() or "\u4e00" <= s[look_ahead] <= "\u9fff"
333
+ ):
334
+ i += 1
335
+ continue
336
+ if char in ["?", "?", "!", "!"]:
337
+ repeat_count = 1
338
+ while i + repeat_count < len(s) and s[i + repeat_count] == char:
339
+ repeat_count += 1
340
+ if repeat_count > 1:
341
+ current += char * (repeat_count - 1)
342
+ i += repeat_count - 1
343
+
344
+ # Glue an emoji directly after terminal punctuation to the current
345
+ # sentence instead of letting it start the next bubble.
346
+ if char in ["?", "?", "!", "!", "。", "."]:
347
+ emoji_look = i + 1
348
+ if emoji_look < len(s) and is_emoji_char(s[emoji_look]):
349
+ while emoji_look < len(s) and is_emoji_char(s[emoji_look]):
350
+ current += s[emoji_look]
351
+ emoji_look += 1
352
+ i = emoji_look - 1
353
+ if emoji_look < len(s) and s[emoji_look] == "\n":
354
+ if current.strip():
355
+ result.append(current.strip())
356
+ current = ""
357
+ elif emoji_look < len(s) and s[emoji_look].isspace():
358
+ if current.strip() and len(result) < 2:
359
+ result.append(current.strip())
360
+ current = ""
361
+ i += 1
362
+ continue
363
+
364
+ if char in [",", ",", ";", ";"]:
365
+ first_chunk_prefix = current[:-1].strip()
366
+ if len(result) == 0 and first_chunk_prefix in {
367
+ "等等", "等下", "等一下", "稍等", "先等等", "先等下",
368
+ }:
369
+ i += 1
370
+ continue
371
+ current_len = len(current.strip())
372
+
373
+ exclamation_words_punct = ("哈", "啊", "哇", "哎", "唉", "唔", "嗯", "哼", "咦", "嘿", "噢")
374
+ is_ellipsis_exclamation = False
375
+ stripped_current = current.strip()
376
+ if stripped_current.startswith("……") or stripped_current.startswith("..."):
377
+ after_ellipsis = stripped_current.lstrip("……").lstrip("...")
378
+ if after_ellipsis and after_ellipsis[0] in exclamation_words_punct:
379
+ is_ellipsis_exclamation = True
380
+ if is_ellipsis_exclamation and char in ["?", "?", "!", "!"]:
381
+ look_ahead_punct = i + 1
382
+ while look_ahead_punct < len(s) and s[look_ahead_punct].isspace():
383
+ look_ahead_punct += 1
384
+ if (
385
+ look_ahead_punct < len(s)
386
+ and s[look_ahead_punct] in HARD_BUBBLE_BOUNDARY_ENDINGS
387
+ ):
388
+ i += 1
389
+ continue
390
+ if current_len < 10 and len(result) >= 2:
391
+ i += 1
392
+ continue
393
+
394
+ if char in [",", ",", ";", ";"]:
395
+ if not explicit_space_boundary:
396
+ if current_len < max_len:
397
+ i += 1
398
+ continue
399
+ if (rng or random).random() > comma_split_prob:
400
+ i += 1
401
+ continue
402
+ else:
403
+ very_short_threshold = 10
404
+ is_very_short = current_len < very_short_threshold
405
+ is_long_enough = current_len >= min_split_len
406
+ if not explicit_space_boundary and not is_very_short and not is_long_enough:
407
+ i += 1
408
+ continue
409
+ if len(result) >= 2:
410
+ i += 1
411
+ continue
412
+
413
+ if current.strip():
414
+ if explicit_space_boundary and any(
415
+ current.strip().endswith(ending)
416
+ for ending in EXPLICIT_SPACE_BOUNDARY_ENDINGS
417
+ ):
418
+ result.append(current.strip())
419
+ elif char in [",", ",", "。", ";", ";"]:
420
+ result.append(current[:-1].strip())
421
+ else:
422
+ result.append(current.strip())
423
+ current = ""
424
+ i += 1
425
+
426
+ if current.strip():
427
+ result.append(current.strip())
428
+
429
+ if len(result) > 1:
430
+ result = merge_continuation_chunks(
431
+ result, max_merge_len=max_len * 2, min_split_len=min_split_len
432
+ )
433
+ if len(result) > 6:
434
+ result = merge_chunks_to_limit(result, max_chunks=6)
435
+ return repair_broken_inline_spans(result)
436
+
437
+ if len(s) > max_len * 2:
438
+ forced = force_split_long_sentence(s, max_len=max_len, max_chunks=6)
439
+ if len(forced) > 1:
440
+ return repair_broken_inline_spans(forced)
441
+
442
+ return [s]
443
+
444
+
445
+ __all__ = ["split_chat_message"]
@@ -0,0 +1,79 @@
1
+ """Set similarity measures with unambiguous names.
2
+
3
+ The runtime had four implementations of "how similar are these two sets"; two
4
+ were containment, one used ``max`` as the denominator while being called
5
+ "similarity", and one was Jaccard. All four are provided here under names that
6
+ state their actual denominator.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Iterable, Set
13
+
14
+ _CJK_PATTERN = re.compile(r"[\u4e00-\u9fff]")
15
+
16
+
17
+ def intersection_over_max(a: Iterable, b: Iterable) -> float:
18
+ """``|A∩B| / max(|A|, |B|)``.
19
+
20
+ This is **not** Jaccard (whose denominator is the union) and **not**
21
+ containment (which is 1.0 when one set is contained in the other).
22
+ """
23
+ set_a, set_b = set(a), set(b)
24
+ if not set_a or not set_b:
25
+ return 0.0
26
+ return len(set_a & set_b) / float(max(len(set_a), len(set_b), 1))
27
+
28
+
29
+ def containment(a: Iterable, b: Iterable) -> float:
30
+ """``|A∩B| / min(|A|, |B|)`` — how much the shorter set is covered."""
31
+ set_a, set_b = set(a), set(b)
32
+ if not set_a or not set_b:
33
+ return 0.0
34
+ return len(set_a & set_b) / float(min(len(set_a), len(set_b)))
35
+
36
+
37
+ def jaccard(a: Iterable, b: Iterable) -> float:
38
+ """``|A∩B| / |A∪B|``."""
39
+ set_a, set_b = set(a), set(b)
40
+ if not set_a or not set_b:
41
+ return 0.0
42
+ return len(set_a & set_b) / float(len(set_a | set_b))
43
+
44
+
45
+ def char_ngrams(text: str, n: int = 2) -> Set[str]:
46
+ """Character n-grams; text shorter than ``n`` yields the text itself."""
47
+ s = str(text or "")
48
+ if len(s) < n:
49
+ return {s} if s else set()
50
+ return {s[i : i + n] for i in range(len(s) - n + 1)}
51
+
52
+
53
+ def token_ngrams(tokens: Iterable[str], n: int = 2) -> Set[tuple[str, ...]]:
54
+ """Token n-grams (useful for word-level overlap)."""
55
+ seq = [str(token) for token in tokens]
56
+ if len(seq) < n:
57
+ return {tuple(seq)} if seq else set()
58
+ return {tuple(seq[i : i + n]) for i in range(len(seq) - n + 1)}
59
+
60
+
61
+ def cjk_chars(text: str) -> Set[str]:
62
+ """Set of CJK characters in the text."""
63
+ return set(_CJK_PATTERN.findall(str(text or "")))
64
+
65
+
66
+ def cjk_bigrams(text: str) -> Set[str]:
67
+ """CJK-only character bigrams."""
68
+ return char_ngrams("".join(_CJK_PATTERN.findall(str(text or ""))), 2)
69
+
70
+
71
+ __all__ = [
72
+ "intersection_over_max",
73
+ "containment",
74
+ "jaccard",
75
+ "char_ngrams",
76
+ "token_ngrams",
77
+ "cjk_chars",
78
+ "cjk_bigrams",
79
+ ]