sentence_generator.py (5772B)
1 """Generate practice sentences from a constrained word pool. 2 3 Uses NLTK's Brown and Gutenberg corpora — filters real English sentences 4 to those where every word belongs to the target pool. Falls back to 5 simple templates when the pool is too small to find corpus matches. 6 """ 7 8 import re 9 import random 10 11 _corpus_cache = None 12 13 14 def _normalize(word): 15 """Lowercase, strip non-alpha except apostrophes (for contractions).""" 16 return re.sub(r"[^a-z']", "", word.lower()) 17 18 19 def _load_corpus(): 20 """Lazily load and pre-process NLTK sentences into (word_set, text) pairs.""" 21 global _corpus_cache 22 if _corpus_cache is not None: 23 return _corpus_cache 24 25 from nltk.corpus import brown, gutenberg 26 27 raw = list(brown.sents()) + list(gutenberg.sents()) 28 corpus = [] 29 for tokens in raw: 30 clean = _rebuild(tokens) 31 if not clean: 32 continue 33 words = {_normalize(w) for w in clean.split()} 34 words.discard("") 35 if len(words) < 3: 36 continue 37 corpus.append((words, clean)) 38 39 _corpus_cache = corpus 40 return corpus 41 42 43 def _rebuild(tokens): 44 """Reconstruct a sentence from NLTK tokens into clean readable text.""" 45 text = " ".join(tokens) 46 # Fix NLTK tokenization artifacts 47 text = re.sub(r" ([.,;:!?)\]}])", r"\1", text) 48 text = re.sub(r"([\[({]) ", r"\1", text) 49 text = text.replace("`` ", '"').replace(" ''", '"').replace("''", '"') 50 text = text.replace('``', '"') 51 text = text.replace(" n't", "n't").replace(" 're", "'re") 52 text = text.replace(" 've", "'ve").replace(" 'll", "'ll") 53 text = text.replace(" 'd", "'d").replace(" 's", "'s") 54 text = text.replace(" 'm", "'m") 55 # Strip leading/trailing quotes and whitespace 56 text = text.strip().strip('"').strip() 57 # Drop sentences with numbers 58 if re.search(r"\d", text): 59 return "" 60 # Must start with a letter and end with sentence-ending punctuation 61 if not text or not text[0].isalpha(): 62 return "" 63 if not text.endswith((".", "!", "?")): 64 return "" 65 # Ensure first character is uppercase 66 text = text[0].upper() + text[1:] 67 # Skip very short or very long 68 word_count = len(text.split()) 69 if word_count < 5 or word_count > 18: 70 return "" 71 # Skip sentences with remaining quote marks (dialogue fragments) 72 if '"' in text or '``' in text or "''" in text: 73 return "" 74 return text 75 76 77 def _filter_corpus(word_pool, count): 78 """Return up to `count` corpus sentences using only words from pool.""" 79 corpus = _load_corpus() 80 pool = {w.lower() for w in word_pool} 81 # Also include common contractions if their base is in the pool 82 extras = set() 83 for w in list(pool): 84 for suffix in ("n't", "'s", "'re", "'ve", "'ll", "'d", "'m"): 85 extras.add(w + suffix) 86 pool |= extras 87 88 matches = [] 89 for word_set, text in corpus: 90 if word_set.issubset(pool): 91 matches.append(text) 92 93 random.shuffle(matches) 94 return matches[:count] 95 96 97 # ── Fallback template generator for very small pools ── 98 99 _PRONOUNS = {"i", "he", "she", "we", "they", "you", "it"} 100 _VERBS = { 101 "be", "have", "do", "say", "go", "get", "make", "know", "take", "come", 102 "see", "think", "look", "want", "give", "use", "find", "tell", "ask", 103 "work", "seem", "feel", "try", "leave", "call", "keep", "let", "begin", 104 "run", "read", "show", "turn", "play", "learn", "set", "change", "move", 105 "put", "start", "need", "help", "talk", "open", "mean", "add", "live", 106 } 107 _NOUNS = { 108 "time", "people", "year", "day", "way", "man", "world", "life", "hand", 109 "part", "place", "thing", "child", "eye", "woman", "work", "case", 110 "point", "home", "water", "room", "mother", "area", "money", "story", 111 "fact", "month", "lot", "right", "study", "book", "job", "word", "side", 112 "head", "house", "name", "end", "door", "car", "food", "night", "state", 113 } 114 _DETS = {"the", "a", "an", "this", "that", "some", "any", "no"} 115 _PREPS = {"in", "on", "at", "to", "for", "with", "from", "by", "about", "into", "over"} 116 _AUXS = {"will", "would", "can", "could", "should", "must", "may", "might"} 117 118 _TEMPLATES = [ 119 ("SUBJ", "VERB", "DET", "NOUN"), 120 ("SUBJ", "AUX", "VERB"), 121 ("SUBJ", "VERB", "PREP", "DET", "NOUN"), 122 ("DET", "NOUN", "VERB", "PREP", "DET", "NOUN"), 123 ("SUBJ", "AUX", "VERB", "DET", "NOUN"), 124 ] 125 126 _SLOT_MAP = { 127 "SUBJ": _PRONOUNS, "VERB": _VERBS, "NOUN": _NOUNS, 128 "DET": _DETS, "PREP": _PREPS, "AUX": _AUXS, 129 } 130 131 132 def _template_sentences(word_pool, count): 133 pool = {w.lower() for w in word_pool} 134 available = {} 135 for slot, candidates in _SLOT_MAP.items(): 136 words = list(pool & candidates) 137 if words: 138 available[slot] = words 139 140 usable = [t for t in _TEMPLATES if all(s in available for s in t)] 141 if not usable: 142 return [] 143 144 results = set() 145 for _ in range(count * 10): 146 pattern = random.choice(usable) 147 words = [random.choice(available[s]) for s in pattern] 148 words[0] = words[0].capitalize() 149 words = ["I" if w == "i" else w for w in words] 150 sent = " ".join(words) + "." 151 results.add(sent) 152 if len(results) >= count: 153 break 154 return list(results)[:count] 155 156 157 def generate_sentences(word_pool, count=10): 158 """Generate practice sentences constrained to word_pool. 159 160 Tries corpus filtering first; falls back to templates for small pools. 161 """ 162 if not word_pool: 163 return [] 164 165 sentences = _filter_corpus(word_pool, count) 166 if len(sentences) >= count: 167 return sentences 168 169 # Supplement with template-generated sentences 170 needed = count - len(sentences) 171 sentences.extend(_template_sentences(word_pool, needed)) 172 return sentences[:count]