stream2sentence 0.2.3__tar.gz → 0.2.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stream2sentence
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.
5
5
  Home-page: https://github.com/KoljaB/stream2sentence
6
6
  Author: Kolja Beigel
@@ -19,6 +19,8 @@ Requires-Dist: stanza==1.6.1
19
19
 
20
20
  Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.
21
21
 
22
+ > **Hint:** *If you're interested in state-of-the-art voice solutions you might also want to <strong>have a look at [Linguflex](https://github.com/KoljaB/Linguflex)</strong>, the original project from which stream2sentence is spun off. It lets you control your environment by speaking and is one of the most capable and sophisticated open-source assistants currently available.*
23
+
22
24
  ## Table of Contents
23
25
 
24
26
  - [Features](#features)
@@ -2,6 +2,8 @@
2
2
 
3
3
  Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.
4
4
 
5
+ > **Hint:** *If you're interested in state-of-the-art voice solutions you might also want to <strong>have a look at [Linguflex](https://github.com/KoljaB/Linguflex)</strong>, the original project from which stream2sentence is spun off. It lets you control your environment by speaking and is one of the most capable and sophisticated open-source assistants currently available.*
6
+
5
7
  ## Table of Contents
6
8
 
7
9
  - [Features](#features)
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="stream2sentence",
8
- version="0.2.3",
8
+ version="0.2.4",
9
9
  author="Kolja Beigel",
10
10
  author_email="kolja.beigel@web.de",
11
11
  description="Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.",
@@ -0,0 +1,427 @@
1
+ """
2
+ Real-time processing and delivery of sentences
3
+ from a continuous stream of characters or text chunks
4
+ """
5
+
6
+ from typing import Iterator
7
+ import logging
8
+ import emoji
9
+ import time
10
+ import re
11
+
12
+ current_tokenizer = "nltk"
13
+ stanza_initialized = False
14
+ nltk_initialized = False
15
+ nlp = None
16
+ debug_logging = True
17
+
18
+
19
+ def initialize_nltk():
20
+ """
21
+ Initializes NLTK by downloading required data for sentence tokenization.
22
+ """
23
+ global nltk_initialized
24
+ if nltk_initialized:
25
+ return
26
+
27
+ logging.info("Initializing NLTK")
28
+
29
+ import nltk
30
+ try:
31
+ _ = nltk.data.find('tokenizers/punkt')
32
+ except LookupError:
33
+ nltk.download('punkt')
34
+ nltk_initialized = True
35
+
36
+
37
+ def initialize_stanza(language: str = "en"):
38
+ """
39
+ Initializes Stanza by downloading required data for sentence tokenization.
40
+ """
41
+ global nlp, stanza_initialized
42
+ if stanza_initialized:
43
+ return
44
+
45
+ logging.info("Initializing Stanza")
46
+
47
+ import stanza
48
+ stanza.download(language)
49
+ nlp = stanza.Pipeline(language)
50
+ stanza_initialized = True
51
+
52
+
53
+ def _remove_links(text: str) -> str:
54
+ """
55
+ Removes any links from the input text.
56
+
57
+ Args:
58
+ text (str): Input text
59
+
60
+ Returns:
61
+ str: Text with links removed
62
+ """
63
+ pattern = (
64
+ r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|'
65
+ r'[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
66
+ )
67
+
68
+ return re.sub(pattern, '', text)
69
+
70
+
71
+ def _remove_emojis(text: str) -> str:
72
+ """
73
+ Removes emojis from the input text.
74
+
75
+ Args:
76
+ text (str): Input text
77
+
78
+ Returns:
79
+ str: Text with emojis removed
80
+ """
81
+ return emoji.replace_emoji(text, u'')
82
+
83
+
84
+ def _generate_characters(generator: Iterator[str],
85
+ log_characters: bool = False) -> Iterator[str]:
86
+ """
87
+ Generates individual characters from a text generator.
88
+
89
+ Args:
90
+ generator (Iterator[str]): Input text generator
91
+ log_characters (bool): Whether to log the characters to the console
92
+
93
+ Yields:
94
+ Individual characters from the generator
95
+ """
96
+ if log_characters:
97
+ print("Stream: ", end="", flush=True)
98
+ for chunk in generator:
99
+ for char in chunk:
100
+ if log_characters:
101
+ print(char, end="", flush=True)
102
+ yield char
103
+ if log_characters:
104
+ print()
105
+
106
+
107
+ def _clean_text(text: str,
108
+ cleanup_text_links: bool = False,
109
+ cleanup_text_emojis: bool = False,
110
+ strip_text: bool = True) -> str:
111
+ """
112
+ Cleans the text by removing links and emojis.
113
+
114
+ Args:
115
+ text (str): Input text
116
+ cleanup_text_links (boolean, optional): Remove non-desired links from
117
+ the stream.
118
+ cleanup_text_emojis (boolean, optional): Remove non-desired emojis
119
+ from the stream.
120
+
121
+ Returns:
122
+ str: Cleaned text
123
+ """
124
+ if cleanup_text_links:
125
+ text = _remove_links(text)
126
+ if cleanup_text_emojis:
127
+ text = _remove_emojis(text)
128
+ if strip_text:
129
+ text = text.strip()
130
+ return text
131
+
132
+
133
+ def _tokenize_sentences(text: str, tokenize_sentences=None) -> Iterator[str]:
134
+ """
135
+ Tokenizes sentences from the input text.
136
+
137
+ Args:
138
+ text (str): Input text
139
+ tokenize_sentences (Callable, optional): A function that tokenizes
140
+ sentences from the input text. Defaults to None.
141
+
142
+ Yields:
143
+ Iterator[str]: An iterator of sentences
144
+ """
145
+ if tokenize_sentences:
146
+ sentences = tokenize_sentences(text)
147
+ else:
148
+ nlp_start_time = time.time()
149
+ if current_tokenizer == "nltk":
150
+ import nltk
151
+ sentences = nltk.tokenize.sent_tokenize(text)
152
+ elif current_tokenizer == "stanza":
153
+ import stanza
154
+ global nlp
155
+ doc = nlp(text)
156
+ sentences = [sentence.text for sentence in doc.sentences]
157
+ else:
158
+ raise ValueError(f"Unknown tokenizer: {current_tokenizer}")
159
+ nlp_end_time = time.time()
160
+ logging.info("Time to split sentences: "
161
+ f"{nlp_end_time - nlp_start_time}")
162
+ return sentences
163
+
164
+
165
+ def init_tokenizer(tokenizer: str, language: str = "en"):
166
+ """
167
+ Initializes the sentence tokenizer.
168
+ """
169
+ if tokenizer == "nltk":
170
+ initialize_nltk()
171
+ elif tokenizer == "stanza":
172
+ initialize_stanza(language)
173
+ else:
174
+ logging.warning(f"Unknown tokenizer: {tokenizer}")
175
+
176
+
177
+ def generate_sentences(generator: Iterator[str],
178
+ context_size: int = 12,
179
+ minimum_sentence_length: int = 10,
180
+ minimum_first_fragment_length=10,
181
+ quick_yield_single_sentence_fragment: bool = False,
182
+ cleanup_text_links: bool = False,
183
+ cleanup_text_emojis: bool = False,
184
+ tokenize_sentences=None,
185
+ tokenizer: str = "nltk",
186
+ language: str = "en",
187
+ log_characters: bool = False,
188
+ sentence_fragment_delimiters: str = ".?!;:,\n…)]}。-",
189
+ force_first_fragment_after_words=15,
190
+ ) -> Iterator[str]:
191
+ """
192
+ Generates well-formed sentences from a stream of characters or text chunks
193
+ provided by an input generator.
194
+
195
+ Args:
196
+ generator (Iterator[str]): A generator that yields chunks of text as a
197
+ stream of characters.
198
+ context_size (int): The number of characters used to establish context
199
+ for sentence boundary detection. A larger context improves the
200
+ accuracy of detecting sentence boundaries.
201
+ Default is 12 characters.
202
+ minimum_sentence_length (int): The minimum number of characters a
203
+ sentence must have. If a sentence is shorter, it will be
204
+ concatenated with the following one, improving the overall
205
+ readability. This parameter does not apply to the first sentence
206
+ fragment, which is governed by `minimum_first_fragment_length`.
207
+ Default is 10 characters.
208
+ minimum_first_fragment_length (int): The minimum number of characters
209
+ required for the first sentence fragment before yielding.
210
+ Default is 10 characters.
211
+ quick_yield_single_sentence_fragment (bool): If set to True, the
212
+ generator will yield the first sentence fragment as quickly as
213
+ possible. This is particularly useful for real-time applications
214
+ such as speech synthesis.
215
+ cleanup_text_links (bool): If True, removes hyperlinks from the text
216
+ stream to ensure clean output.
217
+ cleanup_text_emojis (bool): If True, filters out emojis from the text
218
+ stream for clear textual content.
219
+ tokenize_sentences (Callable): A function that tokenizes sentences
220
+ from the input text. Defaults to None.
221
+ tokenizer (str): The tokenizer to use for sentence tokenization.
222
+ Default is "nltk". Can be "nltk" or "stanza".
223
+ language (str): The language to use for sentence tokenization.
224
+ Default is "en". Can be "multilingual" for stanze tokenizer.
225
+ log_characters (bool): If True, logs each character to the console as
226
+ they are processed.
227
+ sentence_fragment_delimiters (str): A string of characters that are
228
+ considered sentence delimiters. Default is ".?!;:,\n…)]}。-".
229
+ force_first_fragment_after_words (int): The number of words after
230
+ which the first sentence fragment is forced to be yielded.
231
+ Default is 15 words.
232
+
233
+ Yields:
234
+ Iterator[str]: An iterator of complete sentences constructed from the
235
+ input text stream. Each yielded sentence meets the specified minimum
236
+ length requirements and is cleaned up if specified.
237
+
238
+ The function maintains a buffer to accumulate text chunks and applies
239
+ natural language processing to detect sentence boundaries.
240
+ It employs various heuristics, such as minimum sentence length and
241
+ sentence delimiters, to ensure the quality of the output sentences.
242
+ The function also provides options to clean up the text stream,
243
+ making it versatile for different types of text processing applications.
244
+ """
245
+
246
+ global current_tokenizer
247
+ current_tokenizer = tokenizer
248
+ init_tokenizer(current_tokenizer, language)
249
+
250
+ buffer = ''
251
+ is_first_sentence = True
252
+ word_count = 0 # Initialize word count
253
+
254
+ for char in _generate_characters(generator, log_characters):
255
+
256
+ if char:
257
+ buffer += char
258
+ buffer = buffer.lstrip()
259
+
260
+ # Update word count
261
+ if char.isspace() or char in sentence_fragment_delimiters:
262
+ word_count += 1
263
+
264
+ print(f"DBG # buffer: {buffer}")
265
+
266
+ if (is_first_sentence
267
+ and len(buffer) > minimum_first_fragment_length
268
+ and quick_yield_single_sentence_fragment):
269
+
270
+ if (buffer[-1] in sentence_fragment_delimiters
271
+ or word_count >= force_first_fragment_after_words):
272
+
273
+ yield_text = _clean_text(
274
+ buffer,
275
+ cleanup_text_links,
276
+ cleanup_text_emojis)
277
+ if debug_logging:
278
+ print(f"s2s: yield 1st {yield_text}, set buffer='', is_first_sentence = False")
279
+ yield yield_text
280
+ buffer = ""
281
+ is_first_sentence = False
282
+ continue
283
+
284
+ # Check if minimum length reached
285
+ if len(buffer) <= minimum_sentence_length + context_size:
286
+ continue
287
+
288
+ # Potential delimiter character has to be a bit away from the end
289
+ # of the buffer. For reliable sentence detection the engine needs
290
+ # enough context to work with
291
+ delimiter_char = buffer[-context_size]
292
+
293
+ if delimiter_char in sentence_fragment_delimiters:
294
+ sentences = _tokenize_sentences(buffer, tokenize_sentences)
295
+ if debug_logging:
296
+ print(f"s2s: sentences tokenizer {sentences}")
297
+ if len(sentences) > 1:
298
+ total_length_except_first = sum(len(sentence) for sentence in sentences[1:])
299
+ if total_length_except_first >= context_size:
300
+ for sentence in sentences[:-1]:
301
+ yield sentence
302
+ #last_sentence = sentences[-1]
303
+ buffer = sentences[-1]
304
+
305
+ # total_length_except_last = sum(len(sentence) for sentence in sentences[:-1])
306
+ # print(f"DBG # total_length_except_last: {total_length_except_last}, len(buffer): {len(buffer)}, context_size: {context_size}")
307
+ # print(f"DBG # total_length_except_last: {total_length_except_last}, len(buffer) - context_size + 1: {len(buffer) - context_size + 1}")
308
+
309
+ # # print(f"DBG # len(sentences[0]): {len(sentences[0])}, len(buffer): {len(buffer)}, context_size: {context_size}")
310
+ # # print(f"DBG # len(sentences[0]): {len(sentences[0])}, len(buffer) - context_size + 1: {len(buffer) - context_size + 1}")
311
+ # if total_length_except_last == len(buffer) - context_size + 1:
312
+ # if debug_logging:
313
+ # print(f"s2s: yield tokenizer {buffer[:-context_size + 1]}")
314
+ # yield_text = _clean_text(
315
+ # buffer[:-context_size + 1],
316
+ # cleanup_text_links,
317
+ # cleanup_text_emojis)
318
+ # yield yield_text
319
+ # buffer = buffer[-context_size + 1:]
320
+ # if debug_logging:
321
+ # print(f"s2s: AFTER yield tokenizer buffer {buffer}")
322
+ # is_first_sentence = False
323
+
324
+ # Yield remaining buffer
325
+ if buffer:
326
+ sentences = _tokenize_sentences(buffer, tokenize_sentences)
327
+ sentence_buffer = ""
328
+
329
+ for sentence in sentences:
330
+ sentence_buffer += sentence
331
+ if len(sentence_buffer) < minimum_sentence_length:
332
+ sentence_buffer += " "
333
+ continue
334
+ yield_text = _clean_text(
335
+ sentence_buffer,
336
+ cleanup_text_links,
337
+ cleanup_text_emojis)
338
+ if debug_logging:
339
+ print(f"s2s: yield remaining {yield_text}, set sentence_buffer=''")
340
+ yield yield_text
341
+
342
+ sentence_buffer = ""
343
+
344
+ if sentence_buffer:
345
+ yield_text = _clean_text(
346
+ sentence_buffer,
347
+ cleanup_text_links,
348
+ cleanup_text_emojis)
349
+ if debug_logging:
350
+ print(f"s2s: yield last sentence_buffer {yield_text}")
351
+ yield yield_text
352
+
353
+
354
+ # def generate_sentences(generator: Iterator[str],
355
+ # context_size: int = 12,
356
+ # minimum_sentence_length: int = 10,
357
+ # minimum_first_fragment_length=10,
358
+ # quick_yield_single_sentence_fragment: bool = False,
359
+ # cleanup_text_links: bool = False,
360
+ # cleanup_text_emojis: bool = False,
361
+ # tokenize_sentences=None,
362
+ # tokenizer: str = "nltk",
363
+ # language: str = "en",
364
+ # log_characters: bool = False,
365
+ # sentence_fragment_delimiters: str = ".?!;:,\n…)]}。-",
366
+ # force_first_fragment_after_words=15,
367
+ # ) -> Iterator[str]:
368
+ # global current_tokenizer
369
+ # current_tokenizer = tokenizer
370
+ # init_tokenizer(current_tokenizer, language)
371
+
372
+ # buffer = ''
373
+ # is_first_sentence = True
374
+ # word_count = 0
375
+
376
+ # def yield_sentence(text):
377
+ # return _clean_text(text, cleanup_text_links, cleanup_text_emojis)
378
+
379
+ # for char in _generate_characters(generator, log_characters):
380
+ # if char:
381
+ # buffer += char
382
+ # buffer = buffer.lstrip()
383
+
384
+ # if char.isspace() or char in sentence_fragment_delimiters:
385
+ # word_count += 1
386
+
387
+ # # Handle first sentence fragment
388
+ # if is_first_sentence and len(buffer) >= minimum_first_fragment_length:
389
+ # if quick_yield_single_sentence_fragment:
390
+ # if (buffer[-1] in sentence_fragment_delimiters
391
+ # or word_count >= force_first_fragment_after_words):
392
+ # yield yield_sentence(buffer)
393
+ # buffer = ""
394
+ # is_first_sentence = False
395
+ # word_count = 0
396
+ # continue
397
+ # elif len(buffer) >= minimum_sentence_length:
398
+ # sentences = _tokenize_sentences(buffer, tokenize_sentences)
399
+ # if sentences:
400
+ # yield yield_sentence(sentences[0])
401
+ # buffer = buffer[len(sentences[0]):].lstrip()
402
+ # is_first_sentence = False
403
+ # word_count = 0
404
+ # continue
405
+
406
+ # # Process remaining text
407
+ # if len(buffer) > context_size:
408
+ # sentences = _tokenize_sentences(buffer[:-context_size], tokenize_sentences)
409
+ # if sentences:
410
+ # complete_sentences = sentences[:-1]
411
+ # for sentence in complete_sentences:
412
+ # if len(sentence) >= minimum_sentence_length:
413
+ # yield yield_sentence(sentence)
414
+ # else:
415
+ # buffer = sentence + ' ' + buffer[len(sentence):].lstrip()
416
+ # break
417
+ # else:
418
+ # buffer = sentences[-1] + buffer[-context_size:]
419
+
420
+ # # Handle remaining buffer
421
+ # if buffer:
422
+ # sentences = _tokenize_sentences(buffer, tokenize_sentences)
423
+ # for sentence in sentences:
424
+ # if len(sentence) >= minimum_sentence_length or sentence == buffer:
425
+ # yield yield_sentence(sentence)
426
+ # elif sentences[-1] == sentence:
427
+ # yield yield_sentence(sentence)
@@ -92,11 +92,15 @@ def _generate_characters(generator: Iterator[str],
92
92
  Yields:
93
93
  Individual characters from the generator
94
94
  """
95
+ if log_characters:
96
+ print("Stream: ", end="", flush=True)
95
97
  for chunk in generator:
96
98
  for char in chunk:
97
99
  if log_characters:
98
100
  print(char, end="", flush=True)
99
101
  yield char
102
+ if log_characters:
103
+ print()
100
104
 
101
105
 
102
106
  def _clean_text(text: str,
@@ -284,14 +288,11 @@ def generate_sentences(generator: Iterator[str],
284
288
  if delimiter_char in sentence_fragment_delimiters:
285
289
  sentences = _tokenize_sentences(buffer, tokenize_sentences)
286
290
  if len(sentences) > 1:
287
- if len(sentences[0]) == len(buffer) - context_size + 1:
288
- yield_text = _clean_text(
289
- buffer[:-context_size + 1],
290
- cleanup_text_links,
291
- cleanup_text_emojis)
292
- yield yield_text
293
- buffer = buffer[-context_size + 1:]
294
- is_first_sentence = False
291
+ total_length_except_first = sum(len(sentence) for sentence in sentences[1:])
292
+ if total_length_except_first >= context_size:
293
+ for sentence in sentences[:-1]:
294
+ yield sentence
295
+ buffer = sentences[-1]
295
296
 
296
297
  # Yield remaining buffer
297
298
  if buffer:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stream2sentence
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.
5
5
  Home-page: https://github.com/KoljaB/stream2sentence
6
6
  Author: Kolja Beigel
@@ -19,6 +19,8 @@ Requires-Dist: stanza==1.6.1
19
19
 
20
20
  Real-time processing and delivery of sentences from a continuous stream of characters or text chunks.
21
21
 
22
+ > **Hint:** *If you're interested in state-of-the-art voice solutions you might also want to <strong>have a look at [Linguflex](https://github.com/KoljaB/Linguflex)</strong>, the original project from which stream2sentence is spun off. It lets you control your environment by speaking and is one of the most capable and sophisticated open-source assistants currently available.*
23
+
22
24
  ## Table of Contents
23
25
 
24
26
  - [Features](#features)
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  setup.py
3
3
  stream2sentence/__init__.py
4
+ stream2sentence/stream2sentence - Kopie.py
4
5
  stream2sentence/stream2sentence.py
5
6
  stream2sentence.egg-info/PKG-INFO
6
7
  stream2sentence.egg-info/SOURCES.txt
@@ -5,8 +5,36 @@ class TestSentenceGenerator(unittest.TestCase):
5
5
 
6
6
  def test_chinese(self):
7
7
  text = "我喜欢读书。天气很好。我们去公园吧。今天是星期五。早上好。这是我的朋友。请帮我。吃饭了吗?我在学中文。晚安。"
8
- expected = ["我喜欢读书。", "天气很好。", "我们去公园吧。", "今天是星期五。", "早上好。", "这是我的朋友。", "请帮我。", "吃饭了吗?", "我在学中文。晚安。"]
9
- sentences = list(generate_sentences(text, minimum_sentence_length = 2, context_size=2, tokenizer="stanza", language="zh"))
8
+ expected = ["我喜欢读书。", "天气很好。", "我们去公园吧。", "今天是星期五。", "早上好。", "这是我的朋友。", "请帮我。吃饭了吗?", "我在学中文。", "晚安。"]
9
+ sentences = list(generate_sentences(text, minimum_sentence_length=2, context_size=2, tokenizer="stanza", language="zh"))
10
+ self.assertEqual(sentences, expected)
11
+
12
+ def test_chinese2(self):
13
+ text = """
14
+ 胡/爷/爷,我/来/给/您/讲/一下/下/周/每/天/的/安/排。
15
+ 周/一/:/9:00-10:00:晨/练/太/极/拳/,/地点/:/活/动/室/。
16
+ 10:30-11:30:园/艺/活/动/菠菜/种/植/,/地点/:/花/园/。
17
+ 14:00-15:00:手/工/制/作/睡/眠/香/囊/,/地点/:/手/工/室/。
18
+ 15:30-16:30:观/看/老/电/影/,/地点/:/影/音/室/。
19
+
20
+ 周/二/:/9:00-10:00:八/段/锦/简/化/版/,/地点/:/大/厅/。
21
+ 10:30-11:30:书/法/练/习/,/地点/:/书/画/室/。
22
+ 14:00-15:00:棋/牌/娱/乐/象/棋/、/围/棋/等/,/地点/:/棋/牌/室/。
23
+ 15:30-16:30:养/生/讲/座/春/天/养/生/1/,/地点/:/会/议/室/。
24
+ 大/厅/"""
25
+ expected = [
26
+ "胡/爷/爷,我/来/给/您/讲/一下/下/周/每/天/的/安/排。",
27
+ "周/一/:/9:00-10:00:晨/练/太/极/拳/,/地点/:/活/动/室/。",
28
+ "10:30-11:30:园/艺/活/动/菠菜/种/植/,/地点/:/花/园/。",
29
+ "14:00-15:00:手/工/制/作/睡/眠/香/囊/,/地点/:/手/工/室/。",
30
+ "15:30-16:30:观/看/老/电/影/,/地点/:/影/音/室/。",
31
+ "周/二/:/9:00-10:00:八/段/锦/简/化/版/,/地点/:/大/厅/。",
32
+ "10:30-11:30:书/法/练/习/,/地点/:/书/画/室/。",
33
+ "14:00-15:00:棋/牌/娱/乐/象/棋/、/围/棋/等/,/地点/:/棋/牌/室/。",
34
+ "15:30-16:30:养/生/讲/座/春/天/养/生/1/,/地点/:/会/议/室/。",
35
+ "大/厅/",
36
+ ]
37
+ sentences = list(generate_sentences(text, minimum_sentence_length=2, context_size=2, tokenizer="stanza", language="zh"))
10
38
  self.assertEqual(sentences, expected)
11
39
 
12
40
  def test_generator(self):