This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user