This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user