90 lines
3.0 KiB
Python
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,
|
|
)
|