stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

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]