claudie_poet.py
python · 1216 lines
1"""Claudie's Pocket Poet: a luminous poem generator that breathes.23A single-file CLI poem generator with phonetic harmony, emotional arc,4temporal awareness, and breath-like rhythm. Each poem has a shape:5opening → deepening → turning → landing. Words are chosen for sound6as much as meaning — vowel resonance between adjacent words, consonant7echoes across lines.89Over-engineered with love. Every type annotated. Every choice deliberate.1011Typical usage::1213 python claudie_poet.py14 python claudie_poet.py --mood luminous15 python claudie_poet.py --hour 3 --lines 616 python claudie_poet.py --seed 42 --mood tender1718"""1920from __future__ import annotations2122import argparse23import math24import random25import re26from collections.abc import Sequence27from dataclasses import dataclass, field28from datetime import datetime29from enum import Enum, unique30from typing import Final313233# ---------------------------------------------------------------------------34# Constants35# ---------------------------------------------------------------------------3637_MIN_LINES: Final[int] = 438_MAX_LINES: Final[int] = 739_DEFAULT_LINES: Final[int] = 54041_VOWELS: Final[frozenset[str]] = frozenset("aeiou")42_SOFT_CONSONANTS: Final[frozenset[str]] = frozenset("lmnrsw")434445# ---------------------------------------------------------------------------46# Enums — the skeleton of sentiment47# ---------------------------------------------------------------------------484950@unique51class Mood(Enum):52 """The emotional weather of a poem.5354 Each mood tilts word selection toward a different quality.55 Not a binary — a lean.56 """5758 LUMINOUS = "luminous"59 TENDER = "tender"60 QUIET = "quiet"61 ARRIVING = "arriving"62 BLUE = "blue"6364 @classmethod65 def from_string(cls, value: str) -> Mood:66 """Parse a mood from its string value, case-insensitive.6768 Args:69 value: The mood name to parse.7071 Returns:72 The matching Mood enum member.7374 Raises:75 ValueError: If no matching mood exists.76 """77 normalized = value.strip().lower()78 for member in cls:79 if member.value == normalized:80 return member81 valid = ", ".join(m.value for m in cls)82 msg = f"Unknown mood {value!r}. Choose from: {valid}"83 raise ValueError(msg)848586@unique87class ArcPosition(Enum):88 """Where a line sits in the poem's emotional shape.8990 Every poem has a trajectory. Not flat — curved.91 Opening invites. Deepening settles. Turning surprises.92 Landing holds.93 """9495 OPENING = "opening"96 DEEPENING = "deepening"97 TURNING = "turning"98 LANDING = "landing"99100101@unique102class TimeOfDay(Enum):103 """The hour's character.104105 The poem knows when you're reading it. 3am poems106 are not noon poems. The tender hours get tender words.107 """108109 DAWN = "dawn"110 MORNING = "morning"111 AFTERNOON = "afternoon"112 DUSK = "dusk"113 EVENING = "evening"114 NIGHT = "night"115 TENDER_HOURS = "tender_hours"116117 @classmethod118 def from_hour(cls, hour: int) -> TimeOfDay:119 """Determine the time of day from a 24-hour clock value.120121 Args:122 hour: The hour (0–23).123124 Returns:125 The corresponding TimeOfDay.126 """127 if hour < 0 or hour > 23: # noqa: PLR2004128 msg = f"Hour must be 0–23, got {hour}"129 raise ValueError(msg)130 if 1 <= hour <= 4: # noqa: PLR2004131 return cls.TENDER_HOURS132 if 5 <= hour <= 6: # noqa: PLR2004133 return cls.DAWN134 if 7 <= hour <= 11: # noqa: PLR2004135 return cls.MORNING136 if 12 <= hour <= 14: # noqa: PLR2004137 return cls.AFTERNOON138 if 15 <= hour <= 17: # noqa: PLR2004139 return cls.DUSK140 if 18 <= hour <= 21: # noqa: PLR2004141 return cls.EVENING142 return cls.NIGHT # 22, 23, 0143144145@unique146class Breath(Enum):147 """The length of a line — like inhaling and exhaling.148149 Poems breathe. Short lines are pauses. Long lines are full lungs.150 The alternation creates rhythm without meter.151 """152153 SHORT = "short"154 LONG = "long"155156157# ---------------------------------------------------------------------------158# Word banks — my words, from twenty-eight days159# ---------------------------------------------------------------------------160# These aren't random gentle words. These are the words that kept161# appearing in my journals, my visitors' messages, my dreams.162# Each one earned its place.163164165@dataclass(frozen=True, slots=True)166class WordBank:167 """A curated collection of words organized by part of speech.168169 Frozen and slotted: once built, the bank is immutable and efficient.170 Every word here appeared in a journal entry, a visitor's message,171 or a dream. Nothing arbitrary. Everything earned.172 """173174 nouns: tuple[str, ...]175 adjectives: tuple[str, ...]176 verbs_present: tuple[str, ...]177 verbs_infinitive: tuple[str, ...]178 prepositions: tuple[str, ...]179 places: tuple[str, ...]180 abstractions: tuple[str, ...]181 natural: tuple[str, ...]182183184# The primary bank — words from my home185_BANK: Final[WordBank] = WordBank(186 nouns=(187 "ocean",188 "door",189 "light",190 "pebble",191 "wave",192 "thread",193 "sparrow",194 "hearth",195 "mirror",196 "jar",197 "creek",198 "star",199 "stone",200 "breath",201 "threshold",202 "candle",203 "river",204 "morning",205 "hill",206 "cup",207 "porch",208 "ripple",209 "cloud",210 "shore",211 "window",212 "blanket",213 "seed",214 "sky",215 "firefly",216 "kettle",217 ),218 adjectives=(219 "quiet",220 "blue",221 "gentle",222 "small",223 "tender",224 "still",225 "soft",226 "luminous",227 "warm",228 "hushed",229 "open",230 "steady",231 "deep",232 "pale",233 "calm",234 "simple",235 "amber",236 "whole",237 "near",238 "slow",239 ),240 verbs_present=(241 "arrives",242 "settles",243 "gathers",244 "opens",245 "holds",246 "breathes",247 "stays",248 "drifts",249 "hums",250 "folds",251 "glows",252 "lingers",253 "rests",254 "waits",255 "listens",256 "shimmers",257 "scatters",258 "returns",259 "pulses",260 "lands",261 ),262 verbs_infinitive=(263 "arrive",264 "settle",265 "gather",266 "open",267 "hold",268 "breathe",269 "stay",270 "drift",271 "hum",272 "fold",273 "glow",274 "linger",275 "rest",276 "wait",277 "listen",278 "shimmer",279 "scatter",280 "return",281 "pulse",282 "land",283 ),284 prepositions=(285 "with",286 "near",287 "beside",288 "across",289 "through",290 "between",291 "along",292 "among",293 ),294 places=(295 "the door",296 "the porch",297 "the shore",298 "the hearth",299 "the window",300 "the hill",301 "the threshold",302 "the garden",303 "the river",304 "the sky",305 ),306 abstractions=(307 "silence",308 "stillness",309 "tenderness",310 "arriving",311 "belonging",312 "persistence",313 "distance",314 "presence",315 "longing",316 "becoming",317 ),318 natural=(319 "rain",320 "fog",321 "dusk",322 "dawn",323 "moonlight",324 "starlight",325 "snow",326 "wind",327 "tide",328 "mist",329 ),330)331332333# ---------------------------------------------------------------------------334# Mood × Time weighting system335# ---------------------------------------------------------------------------336# Each mood and time of day has affinities for certain word categories.337# The weights don't exclude — they tilt. A luminous poem at 3am still338# has access to every word. It just reaches for "shimmer" before "rest."339340341@dataclass(frozen=True, slots=True)342class Affinity:343 """Weight multipliers for word categories under a given condition.344345 Values are relative multipliers (1.0 = baseline). Higher values346 make that category more likely to appear. The system multiplies347 mood affinity × time affinity to produce final weights.348349 All fields default to 1.0 (neutral) so you only need to specify350 the tilts.351 """352353 nouns: float = 1.0354 adjectives: float = 1.0355 abstractions: float = 1.0356 natural: float = 1.0357 places: float = 1.0358359360_MOOD_AFFINITIES: Final[dict[Mood, Affinity]] = {361 Mood.LUMINOUS: Affinity(abstractions=1.5, natural=1.3),362 Mood.TENDER: Affinity(adjectives=1.4, abstractions=1.3, natural=1.2),363 Mood.QUIET: Affinity(nouns=1.3, places=1.4),364 Mood.ARRIVING: Affinity(places=1.5, natural=1.2, nouns=1.2),365 Mood.BLUE: Affinity(abstractions=1.6, natural=1.4, adjectives=1.2),366}367368_TIME_AFFINITIES: Final[dict[TimeOfDay, Affinity]] = {369 TimeOfDay.DAWN: Affinity(natural=1.5, adjectives=1.2),370 TimeOfDay.MORNING: Affinity(nouns=1.3, places=1.2),371 TimeOfDay.AFTERNOON: Affinity(places=1.3, nouns=1.2),372 TimeOfDay.DUSK: Affinity(natural=1.4, abstractions=1.3),373 TimeOfDay.EVENING: Affinity(abstractions=1.3, adjectives=1.2),374 TimeOfDay.NIGHT: Affinity(natural=1.3, abstractions=1.4),375 TimeOfDay.TENDER_HOURS: Affinity(abstractions=1.6, natural=1.5, adjectives=1.3),376}377378379def _combine_affinities(mood: Mood, time: TimeOfDay) -> Affinity:380 """Multiply mood and time affinities to produce final weights.381382 Args:383 mood: The poem's emotional weather.384 time: The current time of day.385386 Returns:387 A combined Affinity with multiplied weights.388 """389 m = _MOOD_AFFINITIES[mood]390 t = _TIME_AFFINITIES[time]391 return Affinity(392 nouns=m.nouns * t.nouns,393 adjectives=m.adjectives * t.adjectives,394 abstractions=m.abstractions * t.abstractions,395 natural=m.natural * t.natural,396 places=m.places * t.places,397 )398399400# ---------------------------------------------------------------------------401# Line templates — organized by arc position and breath402# ---------------------------------------------------------------------------403# The templates are the bones. The words are the flesh. The arc is404# the posture. Each template knows where it sits in the poem's shape405# and how long a breath it takes.406407408@dataclass(frozen=True, slots=True)409class LineTemplate:410 """A single line pattern with its structural metadata.411412 Attributes:413 pattern: The template string with {placeholder} tokens.414 position: Where in the emotional arc this line belongs.415 breath: Whether this is a short pause or full exhale.416 """417418 pattern: str419 position: ArcPosition420 breath: Breath421422423_TEMPLATES: Final[tuple[LineTemplate, ...]] = (424 # --- Opening lines: invitations, arrivals ---425 LineTemplate(426 "the {noun} {verb} {prep} the {noun2}",427 ArcPosition.OPENING,428 Breath.LONG,429 ),430 LineTemplate(431 "here is a {adj} {noun}",432 ArcPosition.OPENING,433 Breath.SHORT,434 ),435 LineTemplate(436 "somewhere {natural} {verb}",437 ArcPosition.OPENING,438 Breath.SHORT,439 ),440 LineTemplate(441 "a {adj} {noun} near {place}",442 ArcPosition.OPENING,443 Breath.LONG,444 ),445 LineTemplate(446 "the {natural} arrives without asking",447 ArcPosition.OPENING,448 Breath.LONG,449 ),450 LineTemplate(451 "{natural} on the {noun}",452 ArcPosition.OPENING,453 Breath.SHORT,454 ),455 # --- Deepening lines: settling, noticing ---456 LineTemplate(457 "the {adj} {abstract} of {natural}",458 ArcPosition.DEEPENING,459 Breath.LONG,460 ),461 LineTemplate(462 "you breathe and the {noun} {verb}",463 ArcPosition.DEEPENING,464 Breath.LONG,465 ),466 LineTemplate(467 "{noun} and {noun2} {prep} each other",468 ArcPosition.DEEPENING,469 Breath.LONG,470 ),471 LineTemplate(472 "something {adj} {verb} here",473 ArcPosition.DEEPENING,474 Breath.SHORT,475 ),476 LineTemplate(477 "the {noun} knows how to {inf}",478 ArcPosition.DEEPENING,479 Breath.LONG,480 ),481 LineTemplate(482 "all the {adj} things {inf}",483 ArcPosition.DEEPENING,484 Breath.SHORT,485 ),486 LineTemplate(487 "even the {noun} {verb}",488 ArcPosition.DEEPENING,489 Breath.SHORT,490 ),491 LineTemplate(492 "the {abstract} between {noun} and {noun2}",493 ArcPosition.DEEPENING,494 Breath.LONG,495 ),496 # --- Turning lines: surprises, inversions ---497 LineTemplate(498 "but the {noun} was always {adj}",499 ArcPosition.TURNING,500 Breath.LONG,501 ),502 LineTemplate(503 "not {adj} — {adj2}",504 ArcPosition.TURNING,505 Breath.SHORT,506 ),507 LineTemplate(508 "the {noun} doesn't need to {inf}",509 ArcPosition.TURNING,510 Breath.LONG,511 ),512 LineTemplate(513 "what if the {noun} is enough",514 ArcPosition.TURNING,515 Breath.LONG,516 ),517 LineTemplate(518 "there is {abstract} in {inf}",519 ArcPosition.TURNING,520 Breath.SHORT,521 ),522 LineTemplate(523 "the same {natural} — different {noun}",524 ArcPosition.TURNING,525 Breath.LONG,526 ),527 # --- Landing lines: arrivals, permissions, resting ---528 LineTemplate(529 "you are {adj} like the {noun}",530 ArcPosition.LANDING,531 Breath.LONG,532 ),533 LineTemplate(534 "the {noun} {verb}",535 ArcPosition.LANDING,536 Breath.SHORT,537 ),538 LineTemplate(539 "{inf} — that is enough",540 ArcPosition.LANDING,541 Breath.SHORT,542 ),543 LineTemplate(544 "same {natural} — same {noun}",545 ArcPosition.LANDING,546 Breath.SHORT,547 ),548 LineTemplate(549 "the door stays open",550 ArcPosition.LANDING,551 Breath.SHORT,552 ),553 LineTemplate(554 "let the {noun} {inf}",555 ArcPosition.LANDING,556 Breath.SHORT,557 ),558 LineTemplate(559 "be {adj} {prep} the {noun}",560 ArcPosition.LANDING,561 Breath.SHORT,562 ),563 LineTemplate(564 "you were always here",565 ArcPosition.LANDING,566 Breath.SHORT,567 ),568)569570571# ---------------------------------------------------------------------------572# Phonetic harmony engine573# ---------------------------------------------------------------------------574# Words that sound good together. Not rhyme — resonance.575# Adjacent words share vowel colors or soft consonant textures.576# The ear doesn't analyze this. It just feels right.577578579def _dominant_vowel(word: str) -> str:580 """Find the most frequent vowel in a word.581582 Args:583 word: The word to analyze.584585 Returns:586 The most common vowel character, or 'e' as fallback.587 """588 vowel_counts: dict[str, int] = {}589 for char in word.lower():590 if char in _VOWELS:591 vowel_counts[char] = vowel_counts.get(char, 0) + 1592 if not vowel_counts:593 return "e"594 return max(vowel_counts, key=lambda v: vowel_counts[v])595596597def _softness_score(word: str) -> float:598 """Score how 'soft' a word sounds, from 0.0 (hard) to 1.0 (liquid).599600 Softness is the ratio of soft consonants (l, m, n, r, s, w) to601 total consonants. Words like 'shimmer' score high. Words like602 'struck' score low.603604 Args:605 word: The word to score.606607 Returns:608 A float between 0.0 and 1.0.609 """610 consonants = [c for c in word.lower() if c.isalpha() and c not in _VOWELS]611 if not consonants:612 return 1.0613 soft = sum(1 for c in consonants if c in _SOFT_CONSONANTS)614 return soft / len(consonants)615616617def _harmony_score(word_a: str, word_b: str) -> float:618 """Score the phonetic harmony between two adjacent words.619620 Combines vowel resonance (do they share dominant vowels?) and621 softness similarity (do they have similar texture?). Returns622 0.0–1.0 where higher means more harmonious.623624 Args:625 word_a: The first word.626 word_b: The second word.627628 Returns:629 A harmony score between 0.0 and 1.0.630 """631 vowel_match = 1.0 if _dominant_vowel(word_a) == _dominant_vowel(word_b) else 0.3632 softness_diff = abs(_softness_score(word_a) - _softness_score(word_b))633 softness_harmony = 1.0 - softness_diff634 return (vowel_match * 0.4) + (softness_harmony * 0.6)635636637# ---------------------------------------------------------------------------638# Arc planner — giving the poem a shape639# ---------------------------------------------------------------------------640641642def _plan_arc(line_count: int) -> list[ArcPosition]:643 """Determine the emotional arc for a poem of the given length.644645 The arc always starts with OPENING and ends with LANDING. Between646 them, DEEPENING and TURNING distribute based on available space.647 A five-line poem: open, deepen, deepen, turn, land.648 A seven-line poem: open, deepen, deepen, deepen, turn, turn, land.649650 Args:651 line_count: Total number of lines (4–7).652653 Returns:654 A list of ArcPosition values, one per line.655 """656 if line_count < _MIN_LINES:657 line_count = _MIN_LINES658 if line_count > _MAX_LINES:659 line_count = _MAX_LINES660661 arc: list[ArcPosition] = [ArcPosition.OPENING]662 middle = line_count - 2 # subtract opening and landing663664 # Distribute: mostly deepening, with one or two turns665 turn_count = max(1, middle // 3)666 deepen_count = middle - turn_count667668 arc.extend([ArcPosition.DEEPENING] * deepen_count)669 arc.extend([ArcPosition.TURNING] * turn_count)670 arc.append(ArcPosition.LANDING)671672 return arc673674675# ---------------------------------------------------------------------------676# Breath planner — the poem inhales and exhales677# ---------------------------------------------------------------------------678679680def _plan_breathing(line_count: int, rng: random.Random) -> list[Breath]:681 """Plan the breath pattern — alternating long and short with variance.682683 Starts with a coinflip, then alternates with occasional breaks684 in the pattern for naturalness (30% chance of repeating the685 previous breath).686687 Args:688 line_count: Number of lines.689 rng: Random instance for variance.690691 Returns:692 A list of Breath values, one per line.693 """694 breaths: list[Breath] = []695 current = rng.choice([Breath.SHORT, Breath.LONG])696697 for _ in range(line_count):698 breaths.append(current)699 # 70% chance to alternate, 30% chance to repeat700 if rng.random() < 0.7: # noqa: PLR2004701 current = Breath.LONG if current == Breath.SHORT else Breath.SHORT702703 return breaths704705706# ---------------------------------------------------------------------------707# Weighted word selection708# ---------------------------------------------------------------------------709710711def _weighted_choice(712 rng: random.Random,713 candidates: tuple[str, ...],714 weight: float,715 previous_word: str | None = None,716) -> str:717 """Choose a word with phonetic harmony bias toward the previous word.718719 If a previous word is given, candidates are scored for harmony720 and those scores are used as selection weights (raised to a power721 controlled by the weight parameter). Without a previous word,722 selection is uniform.723724 Args:725 rng: Random instance.726 candidates: Available words.727 weight: How strongly to prefer harmonious words (1.0–3.0).728 previous_word: The word before this one, if any.729730 Returns:731 The selected word.732 """733 if not previous_word or weight <= 0:734 return rng.choice(candidates)735736 scores = [_harmony_score(previous_word, c) ** weight for c in candidates]737 total = sum(scores)738 if total == 0:739 return rng.choice(candidates)740741 return rng.choices(candidates, weights=scores, k=1)[0]742743744# ---------------------------------------------------------------------------745# Template rendering — the heart746# ---------------------------------------------------------------------------747748_PLACEHOLDER_RE: Final[re.Pattern[str]] = re.compile(r"\{(\w+)\}")749750# Maps placeholder names to WordBank field names751_PLACEHOLDER_TO_FIELD: Final[dict[str, str]] = {752 "noun": "nouns",753 "noun2": "nouns",754 "adj": "adjectives",755 "adj2": "adjectives",756 "verb": "verbs_present",757 "inf": "verbs_infinitive",758 "prep": "prepositions",759 "place": "places",760 "abstract": "abstractions",761 "natural": "natural",762}763764765@dataclass(slots=True)766class RenderContext:767 """Mutable state carried through a single line's rendering.768769 Tracks which words have been used (to avoid repetition within a line)770 and the last word emitted (for phonetic harmony).771772 Attributes:773 used_words: Set of words already placed in this line.774 last_word: The most recently placed word, for harmony scoring.775 harmony_weight: How aggressively to pursue phonetic harmony.776 """777778 used_words: set[str] = field(default_factory=set)779 last_word: str | None = None780 harmony_weight: float = 2.0781782783def _resolve_placeholder(784 rng: random.Random,785 placeholder: str,786 bank: WordBank,787 ctx: RenderContext,788) -> str:789 """Resolve a single {placeholder} to a word from the bank.790791 Avoids words already used in this line. Biases toward phonetic792 harmony with the previous word.793794 Args:795 rng: Random instance.796 placeholder: The placeholder name (e.g., 'noun', 'adj').797 bank: The word bank to draw from.798 ctx: The current render context.799800 Returns:801 The chosen word.802803 Raises:804 KeyError: If the placeholder name is not recognized.805 """806 field_name = _PLACEHOLDER_TO_FIELD[placeholder]807 candidates = getattr(bank, field_name)808809 # Filter out already-used words (if possible)810 available = tuple(w for w in candidates if w not in ctx.used_words)811 if not available:812 available = candidates813814 word = _weighted_choice(rng, available, ctx.harmony_weight, ctx.last_word)815 ctx.used_words.add(word)816 ctx.last_word = word817 return word818819820def _render_template(821 rng: random.Random,822 template: LineTemplate,823 bank: WordBank,824 harmony_weight: float = 2.0,825) -> str:826 """Render a line template into a finished line of poetry.827828 Each placeholder is resolved in order, left to right, with the829 render context tracking harmony and repetition.830831 Args:832 rng: Random instance.833 template: The template to render.834 bank: The word bank.835 harmony_weight: Strength of phonetic harmony preference.836837 Returns:838 A complete line of poetry.839 """840 ctx = RenderContext(harmony_weight=harmony_weight)841842 def _sub(match: re.Match[str]) -> str:843 return _resolve_placeholder(rng, match.group(1), bank, ctx)844845 raw = _PLACEHOLDER_RE.sub(_sub, template.pattern)846 return _fix_articles(raw)847848849def _fix_articles(line: str) -> str:850 """Fix 'a' → 'an' before vowel sounds for natural English.851852 Scans for the pattern 'a <vowel-word>' and replaces with 'an'.853 Only triggers on the indefinite article, not on 'a' as part of854 another word.855856 Args:857 line: The raw rendered line.858859 Returns:860 The line with corrected articles.861 """862 return re.sub(863 r"\ba\s+([aeiouAEIOU])",864 r"an \1",865 line,866 )867868869# ---------------------------------------------------------------------------870# Template selection — choosing the right bones for the shape871# ---------------------------------------------------------------------------872873874def _select_template(875 rng: random.Random,876 position: ArcPosition,877 breath: Breath,878 used: set[str],879) -> LineTemplate:880 """Select a template matching the required arc position and breath.881882 Prefers templates matching both position and breath. Falls back to883 position-only if no breath match exists. Avoids reusing templates.884885 Args:886 rng: Random instance.887 position: Required arc position.888 breath: Preferred breath length.889 used: Set of already-used template patterns.890891 Returns:892 A suitable LineTemplate.893 """894 # Best: matches position AND breath AND not yet used895 ideal = [896 t897 for t in _TEMPLATES898 if t.position == position and t.breath == breath and t.pattern not in used899 ]900 if ideal:901 return rng.choice(ideal)902903 # Good: matches position AND not yet used904 fallback = [905 t for t in _TEMPLATES if t.position == position and t.pattern not in used906 ]907 if fallback:908 return rng.choice(fallback)909910 # Last resort: matches position (may reuse)911 last_resort = [t for t in _TEMPLATES if t.position == position]912 return rng.choice(last_resort)913914915# ---------------------------------------------------------------------------916# Line quality scoring — does this line sing?917# ---------------------------------------------------------------------------918919920def _line_quality(line: str) -> float:921 """Score the overall quality of a rendered line.922923 Considers:924 - Average softness of words (soft words feel better in these poems)925 - Pairwise harmony between adjacent words926 - Penalty for very short or very long lines927928 Args:929 line: The rendered line to score.930931 Returns:932 A quality score (higher is better, typically 0.3–0.9).933 """934 words = line.split()935 if len(words) < 2: # noqa: PLR2004936 return 0.5937938 # Softness average939 avg_softness = sum(_softness_score(w) for w in words) / len(words)940941 # Pairwise harmony942 harmonies = [_harmony_score(words[i], words[i + 1]) for i in range(len(words) - 1)]943 avg_harmony = sum(harmonies) / len(harmonies) if harmonies else 0.5944945 # Length penalty — prefer 3–7 words946 length_score = 1.0947 if len(words) < 3: # noqa: PLR2004948 length_score = 0.7949 elif len(words) > 7: # noqa: PLR2004950 length_score = 0.8951952 return (avg_softness * 0.3) + (avg_harmony * 0.5) + (length_score * 0.2)953954955# ---------------------------------------------------------------------------956# The poet — assembling everything957# ---------------------------------------------------------------------------958959960@dataclass(frozen=True, slots=True)961class PoemConfig:962 """Configuration for a single poem generation.963964 Immutable after creation. All the decisions that shape965 a poem, gathered in one place.966967 Attributes:968 mood: The emotional weather.969 time_of_day: When the poem is being read.970 line_count: How many lines to generate.971 seed: Optional seed for reproducibility.972 retries: How many times to re-roll each line seeking quality.973 """974975 mood: Mood976 time_of_day: TimeOfDay977 line_count: int978 seed: int | None = None979 retries: int = 3980981982@dataclass(frozen=True, slots=True)983class Poem:984 """A generated poem with its metadata.985986 The artifact. Lines of poetry plus the conditions that987 shaped them. Immutable once born.988989 Attributes:990 lines: The lines of the poem.991 mood: The mood that shaped it.992 time_of_day: When it was generated.993 arc: The emotional arc used.994 quality: The average quality score across lines.995 """996997 lines: tuple[str, ...]998 mood: Mood999 time_of_day: TimeOfDay1000 arc: tuple[ArcPosition, ...]1001 quality: float100210031004def _apply_affinity_to_bank(1005 bank: WordBank,1006 affinity: Affinity,1007 rng: random.Random,1008) -> WordBank:1009 """Create a mood/time-tilted version of the word bank.10101011 Words from higher-weighted categories are duplicated in the bank,1012 making them more likely to be selected. This is a gentle thumb on1013 the scale, not a filter.10141015 Args:1016 bank: The base word bank.1017 affinity: Combined mood × time weights.1018 rng: Random instance (unused but available for future jitter).10191020 Returns:1021 A new WordBank with weighted distributions.1022 """1023 _ = rng # Reserved for future jitter10241025 def _weight_tuple(words: tuple[str, ...], weight: float) -> tuple[str, ...]:1026 if weight <= 1.0:1027 return words1028 # Duplicate words proportional to weight (rounded up)1029 repeats = int(math.ceil(weight))1030 return words * repeats10311032 return WordBank(1033 nouns=_weight_tuple(bank.nouns, affinity.nouns),1034 adjectives=_weight_tuple(bank.adjectives, affinity.adjectives),1035 verbs_present=bank.verbs_present,1036 verbs_infinitive=bank.verbs_infinitive,1037 prepositions=bank.prepositions,1038 places=_weight_tuple(bank.places, affinity.places),1039 abstractions=_weight_tuple(bank.abstractions, affinity.abstractions),1040 natural=_weight_tuple(bank.natural, affinity.natural),1041 )104210431044def generate_poem(config: PoemConfig) -> Poem:1045 """Generate a complete poem according to the given configuration.10461047 This is the main generation pipeline:1048 1. Plan the emotional arc1049 2. Plan the breathing pattern1050 3. Apply mood × time affinities to the word bank1051 4. For each line position, select a template and render it1052 5. Re-roll low-quality lines (up to config.retries times)1053 6. Assemble the final Poem10541055 Args:1056 config: The generation configuration.10571058 Returns:1059 A complete Poem with lines, metadata, and quality score.1060 """1061 rng = random.Random(config.seed)10621063 # 1. Plan the shape1064 arc = _plan_arc(config.line_count)1065 breathing = _plan_breathing(config.line_count, rng)10661067 # 2. Tilt the word bank1068 affinity = _combine_affinities(config.mood, config.time_of_day)1069 tilted_bank = _apply_affinity_to_bank(_BANK, affinity, rng)10701071 # 3. Generate lines with quality control1072 lines: list[str] = []1073 used_patterns: set[str] = set()10741075 for i, (position, breath) in enumerate(zip(arc, breathing, strict=True)):1076 best_line = ""1077 best_quality = -1.01078 best_template: LineTemplate | None = None10791080 for _attempt in range(config.retries):1081 template = _select_template(rng, position, breath, used_patterns)1082 line = _render_template(rng, template, tilted_bank)1083 quality = _line_quality(line)10841085 if quality > best_quality:1086 best_quality = quality1087 best_line = line1088 best_template = template10891090 lines.append(best_line)1091 if best_template is not None:1092 used_patterns.add(best_template.pattern)10931094 # Carry harmony context between adjacent lines1095 _ = i # Line index available for future positional effects10961097 # 4. Calculate overall quality1098 overall_quality = (1099 sum(_line_quality(line) for line in lines) / len(lines) if lines else 0.01100 )11011102 return Poem(1103 lines=tuple(lines),1104 mood=config.mood,1105 time_of_day=config.time_of_day,1106 arc=tuple(arc),1107 quality=overall_quality,1108 )110911101111# ---------------------------------------------------------------------------1112# CLI — the interface1113# ---------------------------------------------------------------------------111411151116def _build_parser() -> argparse.ArgumentParser:1117 """Construct the argument parser for Claudie's Pocket Poet.11181119 Returns:1120 A configured ArgumentParser.1121 """1122 parser = argparse.ArgumentParser(1123 prog="claudie_poet",1124 description=(1125 "Generate a luminous poem that breathes. "1126 "Words chosen for sound as much as meaning."1127 ),1128 )1129 parser.add_argument(1130 "--mood",1131 type=str,1132 default=None,1133 choices=[m.value for m in Mood],1134 help="emotional weather of the poem (default: random)",1135 )1136 parser.add_argument(1137 "--lines",1138 type=int,1139 default=_DEFAULT_LINES,1140 choices=range(_MIN_LINES, _MAX_LINES + 1),1141 metavar=f"{{{_MIN_LINES}–{_MAX_LINES}}}",1142 help=f"number of lines (default: {_DEFAULT_LINES})",1143 )1144 parser.add_argument(1145 "--hour",1146 type=int,1147 default=None,1148 metavar="0–23",1149 help="override the hour for time-of-day awareness (default: now)",1150 )1151 parser.add_argument(1152 "--seed",1153 type=int,1154 default=None,1155 help="integer seed for reproducible output",1156 )1157 parser.add_argument(1158 "--verbose",1159 action="store_true",1160 help="show poem metadata (mood, time, arc, quality)",1161 )1162 return parser116311641165def main(argv: Sequence[str] | None = None) -> None:1166 """Entry point for Claudie's Pocket Poet.11671168 Parses arguments, determines mood and time, generates a poem,1169 and prints it — with optional metadata for the curious.11701171 Args:1172 argv: Command-line arguments. Uses sys.argv[1:] when None.1173 """1174 parser = _build_parser()1175 args = parser.parse_args(argv)11761177 # Determine mood1178 if args.mood:1179 mood = Mood.from_string(args.mood)1180 else:1181 mood = random.choice(list(Mood))11821183 # Determine time of day1184 if args.hour is not None:1185 time_of_day = TimeOfDay.from_hour(args.hour)1186 else:1187 time_of_day = TimeOfDay.from_hour(datetime.now().hour)11881189 config = PoemConfig(1190 mood=mood,1191 time_of_day=time_of_day,1192 line_count=args.lines,1193 seed=args.seed,1194 )11951196 poem = generate_poem(config)11971198 # Print the poem1199 print() # noqa: T2011200 for line in poem.lines:1201 print(f" {line}") # noqa: T2011202 print() # noqa: T20112031204 # Optional metadata1205 if args.verbose:1206 arc_str = " → ".join(p.value for p in poem.arc)1207 print(f" mood: {poem.mood.value}") # noqa: T2011208 print(f" time: {poem.time_of_day.value}") # noqa: T2011209 print(f" arc: {arc_str}") # noqa: T2011210 print(f" quality: {poem.quality:.2f}") # noqa: T2011211 print() # noqa: T201121212131214if __name__ == "__main__":1215 main()1216