"""funnies theme derivation and find-a-word grid construction. Per functional design: BR1.1 (derive theme words + context from content), BR1.3 (find-a-word places every theme word). Deterministic build; the cloud assistance is only for crossword clues / cartoon context (not find-a-word). """ from __future__ import annotations import random from .model import FindAWord def derive_theme_words(content: str, max_words: int = 12) -> list[str]: """Extract theme words from the content (BR1.1). Simple heuristic: split on non-word chars, drop stopwords/short words and duplicates, keep uppercase-ish + longer words weighted toward themes. Kept dependency-free; a more sophisticated extractor could sit behind this seam. """ stopwords = { "the", "and", "for", "with", "that", "this", "from", "they", "their", "will", "have", "been", "was", "were", "are", "our", "your", "we", "us", "you", "a", "an", "to", "of", "on", "in", "at", "by", "is", "it", "or", "but", "not", "so", "if", "as", "into", "about", "after", "before", } words = [ w.lower().strip(".,!?\"'()[]{}") for w in content.split() ] seen: dict[str, int] = {} for w in words: if not w or w in stopwords or len(w) < 3: continue score = 2 if w[0].isupper() else 1 # proper nouns / names weigh more seen[w] = seen.get(w, 0) + score ranked = sorted(seen.items(), key=lambda kv: kv[1], reverse=True) return [w for w, _ in ranked[:max_words]] def build_find_a_word( words: list[str], grid_size: int = 12, rng: random.Random | None = None, ) -> FindAWord: """Place every theme word into a grid (BR1.3). Returns a printable grid. Deterministic: longest words first, each placed via a systematic row-major scan of every (row, col, direction) until it fits. Guarantees placement for any word the grid is large enough to hold. """ rng = rng or random.Random(20260913) grid = [["." for _ in range(grid_size)] for _ in range(grid_size)] directions = [(0, 1), (1, 0), (1, 1), (-1, 1)] # right, down, diag down-right, diag up-right for word in sorted(words, key=len, reverse=True): placed = _try_place_systematic(word, grid, grid_size, directions) if not placed: # Could not fit on this grid; skip rather than corrupt the build. continue return FindAWord(find_id="faw-1", words=words, grid=grid) def _try_place_systematic( word: str, grid: list[list[str]], size: int, directions ) -> bool: for row in range(size): for col in range(size): for dr, dc in directions: if _fits(word, row, col, dr, dc, grid): _place(word, row, col, dr, dc, grid) return True return False def _fits(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> bool: size = len(grid) end_row = row + dr * (len(word) - 1) end_col = col + dc * (len(word) - 1) if not (0 <= end_row < size and 0 <= end_col < size): return False for i, ch in enumerate(word): r, c = row + dr * i, col + dc * i cell = grid[r][c] if cell != "." and cell != ch: return False return True def _place(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> None: for i, ch in enumerate(word): grid[row + dr * i][col + dc * i] = ch.upper()