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