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
+25
View File
@@ -0,0 +1,25 @@
"""ai-draft — optional local/cloud model copy drafting helper unit."""
from .client import HttpModelClient, build_model_prompt
from .draft import build_brief, draft_newspaper
from .model import (
DraftArticle,
DraftBrief,
DraftBundle,
MastheadConfig,
StatusMessage,
empty_draft_bundle,
)
__all__ = [
"DraftArticle",
"DraftBrief",
"DraftBundle",
"HttpModelClient",
"MastheadConfig",
"StatusMessage",
"build_brief",
"build_model_prompt",
"draft_newspaper",
"empty_draft_bundle",
]
+55
View File
@@ -0,0 +1,55 @@
"""Thin, minimal-surface model client for ai-draft.
Per NFR design (minimal-surface): this is the ONE narrow caller to the granted
model. It builds a minimal prompt from the DraftBrief (content context + tone +
per-type max-length note), calls the cloud model, and parses the response into a
DraftArticle. It is injectable so tests can substitute a stub (no real network
in CI).
"""
from __future__ import annotations
from typing import Protocol
from .model import DraftArticle
class ModelClient(Protocol):
"""Interface for the single sanctioned external call."""
def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle: ...
class HttpModelClient:
"""Default client calling the granted cloud model endpoint.
Endpoint/alias is supplied by the user via a local config (infrastructure
design Q3=A, never committed / hard-coded). Implementation detail for
code-generation: a thin HTTP POST of `brief_text` to the configured
endpoint, response parsed locally into a DraftArticle. The concrete HTTP
transport is wired in code-generation; this class documents the contract.
"""
def __init__(self, model: str = "deepseek-v4-flash:cloud", endpoint: str = "") -> None:
self.model = model
self.endpoint = endpoint
def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle:
# Placeholder transport — the real HTTP call is implemented in
# code-generation. Kept as an interface-level stub per the design-stage
# constraint (≤15 lines, no complete HTTP implementation here).
raise NotImplementedError(
"HttpModelClient transport is wired in code-generation; tests use the stub."
)
def build_model_prompt(brief, article_type: str, max_length: int) -> str:
"""Assemble the minimal prompt (Q4=A, BR2.2/BR3.1/BR3.2): content context +
tone + per-type max-length note. No secrets, only the brief."""
prompt = (
f"Write a {article_type} article.\n"
f"Context: {brief.content_context}\n"
f"Tone: {brief.tone_instruction}\n"
f"Max length: {max_length} characters."
)
return prompt.strip()
+89
View File
@@ -0,0 +1,89 @@
"""ai-draft orchestration.
Per functional design (Q1=A): one model call per requested article type;
Q2=A: each drafted article is its own addressable unit in the DraftBundle;
Q3=A / BR2.1: on model failure, return an empty DraftBundle + StatusMessage.
BR3.1 light max-length guardrail per type (trim/flag, never ship over-length).
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Callable
from .client import ModelClient, build_model_prompt
from .model import (
DraftArticle,
DraftBrief,
DraftBundle,
ArticleKind,
empty_draft_bundle,
)
DEFAULT_MAX_LENGTH_BY_TYPE: dict[str, int] = {
"lead": 2200,
"article": 1600,
"filler": 800,
"wellwish": 500,
}
def _trim_to(article: DraftArticle, max_length: int) -> DraftArticle:
"""BR3.1: trim over-length bodies to the type guardrail."""
if len(article.body) > max_length:
article.body = article.body[:max_length]
return article
def draft_newspaper(
brief: DraftBrief,
model_client: ModelClient,
draft_bundle_id: str = "",
max_length_by_type: dict[str, int] | None = None,
now: Callable[[], str] | None = None,
) -> DraftBundle:
"""Draft the requested article types via one model call each (BR1.2).
On any model failure, return an empty DraftBundle with a StatusMessage
(BR2.1); the generator proceeds without AI. Never throws to the caller.
"""
bundle_id = draft_bundle_id or f"draft-{int(__import__('time').time() * 1000)}"
generated_at = (now or (lambda: datetime.now(timezone.utc).isoformat()))()
lengths = max_length_by_type or DEFAULT_MAX_LENGTH_BY_TYPE
articles: list[DraftArticle] = []
for i, article_type in enumerate(brief.requested_article_types):
article_id = f"{article_type}-{i + 1}"
try:
max_len = lengths.get(article_type, lengths.get("article", 1600))
prompt = build_model_prompt(brief, article_type, max_len)
# One narrow, sanctioned call to the model (NFR9.3 / minimal-surface).
article = model_client.generate(prompt, article_type, article_id)
articles.append(_trim_to(article, max_len))
except Exception as exc: # never crash; fail soft (BR2.1, Q3=A)
return empty_draft_bundle(
bundle_id,
generated_at,
code="MODEL_UNAVAILABLE",
message=f"Could not draft '{article_type}': {exc}",
)
return DraftBundle(
draft_bundle_id=bundle_id,
articles=articles,
generated_at=generated_at,
status="drafted",
)
def build_brief(
content_context: str,
requested_article_types: list[str] | None = None,
model: str = "deepseek-v4-flash:cloud",
) -> DraftBrief:
"""Build the concise content-derived brief (Q4=A)."""
return DraftBrief(
content_context=content_context,
requested_article_types=requested_article_types or ["lead", "filler"],
model=model,
)
+107
View File
@@ -0,0 +1,107 @@
"""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),
)