Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

95 lines
3.4 KiB
Python

"""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()