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