first pass at the newspaper builder
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-14 11:57:22 +10:00
commit bec1eaac87
497 changed files with 178953 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
"""funnies — the newspaper's fun section (crossword, find-a-word, cartoon)."""
from .builder import FunniesBuilder
from .cartoon import (
CartoonSource,
Comic,
StubXkcdCartoonSource,
build_comic,
select_cartoon,
tasteful_fallback,
)
from .crossword import Crossword, StubClueDrafter, build_crossword
from .find_a_word import build_find_a_word, derive_theme_words
from .model import Cartoon, FindAWord, FunniesBrief, FunniesResult
__all__ = [
"Cartoon",
"CartoonSource",
"Comic",
"Crossword",
"FindAWord",
"FunniesBrief",
"FunniesBuilder",
"FunniesResult",
"StubClueDrafter",
"StubXkcdCartoonSource",
"build_comic",
"build_crossword",
"build_find_a_word",
"derive_theme_words",
"select_cartoon",
"tasteful_fallback",
]
+60
View File
@@ -0,0 +1,60 @@
"""funnies — builds the newspaper's fun section from the content itself.
Orchestrates theme derivation, find-a-word, crossword (AI-drafted clues),
cartoon/comic selection into a FunniesResult. Graceful empty/fallback when no
viable content (Q5=A / BR3.2); the emitted result stays self-contained (BR4.1).
"""
from __future__ import annotations
from .crossword import ClueDrafter, build_crossword
from .find_a_word import build_find_a_word, derive_theme_words
from .cartoon import CartoonSource, Comic, build_comic, select_cartoon
from .model import FunniesBrief, FunniesResult
class FunniesBuilder:
"""Funnies library entry point — assemble the funnies section."""
def __init__(
self,
clue_drafter: ClueDrafter | None = None,
cartoon_source: CartoonSource | None = None,
) -> None:
self._clue_drafter = clue_drafter
self._cartoon_source = cartoon_source
def build(self, brief: FunniesBrief) -> FunniesResult:
if not brief.theme_words and not brief.context.strip():
# BR3.2 / Q5=A: nothing viable — graceful empty, never a broken block
return FunniesResult(
funnies_result_id="funnies-1",
status="empty",
status_message="No theme words or context provided; funnies skipped.",
)
words = brief.theme_words or derive_theme_words(brief.context)
if not words:
return FunniesResult(
funnies_result_id="funnies-1",
status="empty",
status_message="Could not derive any theme words from content.",
)
find_word = build_find_a_word(words)
crossword = build_crossword(words, brief.context, self._clue_drafter)
cartoon = select_cartoon(brief.context, self._cartoon_source)
result = FunniesResult(
funnies_result_id="funnies-1",
crossword=crossword,
find_a_word=find_word,
cartoons=[cartoon],
comics=[],
status="ready",
)
# If the cartoon had to fall back, mark fallback (Q5=A)
if cartoon.source == "generated":
result.status = "fallback"
result.status_message = "No xkcd found; used a content-derived strip."
return result
+63
View File
@@ -0,0 +1,63 @@
"""funnies cartoon + comic selection.
Per functional design: BR2.1 (search the real xkcd site at build time, embed
locally), BR2.2 (tasteful content-derived fallback when no cartoon found),
BR3.1 (content-enrichment comics). A build-time internet fetch is allowed to
enrich content; the EMITTED page stays self-contained (NFR4) because fetched
assets are embedded as a local ref, never a remote URL at print.
"""
from __future__ import annotations
from typing import Protocol
from .model import Cartoon, Comic
class CartoonSource(Protocol):
"""Injectably fetches/selects a cartoon from an enrichment source."""
def fetch(self, context: str) -> Cartoon | None: ...
class StubXkcdCartoonSource:
"""Deterministic stub (tests / offline): returns a local-bundled cartoon."""
def __init__(self, found: bool = True) -> None:
self.found = found
def fetch(self, context: str) -> Cartoon | None:
if not self.found:
return None
return Cartoon(
cartoon_id="xkcd-1",
source="xkcd",
art_ref="assets/xkcd-1.svg", # local/bundled — never remote
caption="A cartoon tied to the occasion",
)
def tasteful_fallback(context: str, cartoon_id: str = "fallback-1") -> Cartoon:
"""BR2.2: a tasteful content-derived strip when no cartoon is found.
Returns a locally-derived placeholder (never a remote URL at print).
"""
return Cartoon(
cartoon_id=cartoon_id,
source="generated",
art_ref="assets/fallback-strip.svg",
caption=f"A content-derived strip for: {context[:60]}",
)
def select_cartoon(context: str, source: CartoonSource | None = None) -> Cartoon:
"""BR2.1/BR2.2: fetch an xkcd cartoon, falling back to a tasteful strip."""
src = source or StubXkcdCartoonSource(found=True)
cartoon = src.fetch(context)
return cartoon if cartoon else tasteful_fallback(context)
def build_comic(comic_id: str, source: str, art_ref: str) -> Comic:
"""BR3.1: a comic strip placed in the funnies section (overridable per
article). The caller supplies a local/bundled art ref."""
return Comic(comic_id=comic_id, source=source, art_ref=art_ref)
+66
View File
@@ -0,0 +1,66 @@
"""funnies crossword construction.
Per functional design (BR1.2 / Q1=B): AI-assisted — an injectable clue-drafter
(typically the cloud model) supplies across/down clues; funnies lays them out
onto a 10x10 grid (Q2=B). The clue-drafter is injectable so tests stub it (no
real network in CI). The grid layout is deterministic/dependency-free.
"""
from __future__ import annotations
from typing import Protocol
from .model import Crossword
class ClueDrafter(Protocol):
"""Interface for the AI-assisted clue drafting (the sanctioned cloud call)."""
def draft_clues(
self, theme_words: list[str], context: str, grid_size: int
) -> tuple[list[str], list[str]]: ...
class StubClueDrafter:
"""Deterministic stub used in tests / offline: simple auto-clues."""
def draft_clues(self, theme_words, context, grid_size=10):
across = [f"Word {i + 1}" for i in range(len(theme_words))]
down = ["Crossing clue"] * min(4, len(theme_words))
return across, down
GRID_SIZE = 10
def _blank_grid() -> list[list[str]]:
return [[" " for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)]
def _lay_out_across(words: list[str], base: list[list[str]]) -> None:
"""Place theme words horizontally on row r for the grid."""
for r, word in enumerate(words[:GRID_SIZE]):
if r >= GRID_SIZE:
break
w = word[:GRID_SIZE]
base[r][: len(w)] = list(w.upper())
def build_crossword(
theme_words: list[str],
context: str,
clue_drafter: ClueDrafter | None = None,
) -> Crossword:
"""Build a 10x10 crossword with AI-drafted clues (BR1.2, Q2=B)."""
drafter = clue_drafter or StubClueDrafter()
across, down = drafter.draft_clues(theme_words, context, GRID_SIZE)
grid = _blank_grid()
_lay_out_across(theme_words, grid)
solution = [row[:] for row in grid]
return Crossword(
crossword_id="xw-1",
grid=grid,
clues_across=across,
clues_down=down,
solution=solution,
)
+94
View File
@@ -0,0 +1,94 @@
"""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()
+84
View File
@@ -0,0 +1,84 @@
"""funnies data model.
Per functional-design entities (unit: funnies): FunniesBrief, Crossword,
FindAWord, Cartoon, Comic, FunniesResult. Lightweight plain-data shapes owned
by the funnies library; Contract 2/4. Status ready/fallback/empty keeps failure
graceful (Q5=A / BR3.2).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
FunniesStatus = Literal["ready", "fallback", "empty"]
@dataclass
class FunniesBrief:
"""Content-derived input funnies receives from the generator (Contract 4)."""
theme_words: list[str] = field(default_factory=list)
context: str = ""
cartoon_source: str = "internet-allow"
@dataclass
class Crossword:
"""A single AI-drafted crossword, laid out on a 10x10 grid (Q2=B)."""
crossword_id: str
grid: list[list[str]]
clues_across: list[str]
clues_down: list[str]
solution: list[list[str]]
@dataclass
class FindAWord:
"""A find-a-word puzzle built from theme words (BR1.3)."""
find_id: str
words: list[str]
grid: list[list[str]]
@dataclass
class Cartoon:
"""An embedded single-panel cartoon (xkcd or generated fallback, Q3=A)."""
cartoon_id: str
source: str # xkcd | generated | none
art_ref: str # local/bundled asset (never a remote URL at print; NFR4)
caption: str = ""
@dataclass
class Comic:
"""A comic strip in the funnies section (Q4=A, BR3.1)."""
comic_id: str
source: str
art_ref: str # local/bundled
dialogue: list[str] = field(default_factory=list)
@dataclass
class FunniesResult:
"""The container funnies returns to Generator (Contract 2). Graceful
fallback/empty handling on no viable content (Q5=A / BR3.2)."""
funnies_result_id: str
crossword: Crossword | None = None
find_a_word: FindAWord | None = None
cartoons: list[Cartoon] = field(default_factory=list)
comics: list[Comic] = field(default_factory=list)
status: FunniesStatus = "ready"
status_message: str = ""
def is_self_contained(self) -> bool:
"""BR4.1 / NFR4: no remote URL survives in any art_ref."""
for art in self.cartoons + self.comics:
if art.art_ref.startswith(("http://", "https://")):
return False
return True