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

90 lines
3.0 KiB
Python

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