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

108 lines
3.2 KiB
Python

"""ai-draft data model.
Per functional-design entities (unit: ai-draft): DraftBrief, DraftArticle,
DraftBundle, StatusMessage. Lightweight plain-data shapes owned by the
ai-draft library; per Contract 1/5 the DraftBundle is the per-article review
handoff.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
ArticleKind = Literal["lead", "article", "filler", "wellwish"]
BundleStatus = Literal["drafted", "empty", "partial_error"]
@dataclass
class MastheadConfig:
"""Dynamic masthead/metadata input read from config.json (US7/Q3=A).
Owned by the generator (functional-design), but carried here as a shape the
ai-draft brief can reference for on-theme context (couple names, occasion).
"""
title: str
couple_names: str
date_line: str
issue_number: str
volume: str
@dataclass
class DraftBrief:
"""The concise input ai-draft builds and hands to the cloud model (Q4=A)."""
content_context: str
requested_article_types: list[str] = field(default_factory=lambda: ["lead", "filler"])
model: str = "deepseek-v4-flash:cloud"
tone_instruction: str = (
"Write warm, keepsake-appropriate newspaper copy for a wedding issue."
)
def __post_init__(self) -> None:
if not self.content_context.strip():
raise ValueError("content_context must be non-empty when a draft is requested")
if not self.requested_article_types:
raise ValueError("requested_article_types must contain at least one type")
@dataclass
class DraftArticle:
"""One drafted article, the per-article unit of the review handoff (Q2=A)."""
article_id: str
type: ArticleKind
headline: str
body: str
byline: str = ""
section: str = ""
def round_trip(self) -> bool:
"""All string fields round-trip without dropping/escaping (NFR7-style)."""
return (
isinstance(self.article_id, str)
and isinstance(self.type, str)
and isinstance(self.headline, str)
and isinstance(self.body, str)
and isinstance(self.byline, str)
)
@dataclass
class StatusMessage:
"""A clear, human-readable status note (Q3=A)."""
code: str
message: str
@dataclass
class DraftBundle:
"""The per-article review handoff (Contract 5). status=empty means the model
was unreachable/failed and the generator proceeds without AI (Q3=A / BR2.1)."""
draft_bundle_id: str
articles: list[DraftArticle]
generated_at: str
status: BundleStatus = "drafted"
status_message: StatusMessage | None = None
def article(self, article_id: str) -> DraftArticle | None:
"""Return one article by id, keeping each article addressable (BR1.3)."""
return next((a for a in self.articles if a.article_id == article_id), None)
def empty_draft_bundle(
draft_bundle_id: str, generated_at: str, code: str, message: str
) -> DraftBundle:
"""A fail-soft empty DraftBundle with a clear StatusMessage (BR2.1, Q3=A)."""
return DraftBundle(
draft_bundle_id=draft_bundle_id,
articles=[],
generated_at=generated_at,
status="empty",
status_message=StatusMessage(code=code, message=message),
)