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,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
|
+
]
|