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
@@ -0,0 +1,132 @@
# Functional Design — Entities (unit: ai-draft)
> Source of truth for the entity model of the `ai-draft` library unit. The
> cloud-Model drafting unit owns the DraftBundle and its constituent DraftArticle
> shapes (per Contract 1/5). Per confirmed answers: one call per article kind,
> each drafted article its own entry, light guardrails.
```yaml
entities:
- name: DraftBrief
description: >
The concise input ai-draft builds from content-derived context and hands
to the cloud model to guide a draft. Owned by ai-draft.
attributes:
- name: contentContext
type: string
required: true
description: Concise themes/context pulled from the user's content (couple names, occasion, date, key themes).
- name: requestedArticleTypes
type: list[string]
required: true
default: [lead, filler]
description: Which article kinds to draft (lead, filler, wellwish, ...).
- name: model
type: string
required: true
default: deepseek-v4-flash:cloud
description: The granted model. CLOUD (sanctioned, human-approved); local-only otherwise.
entity_constraints:
- contentContext must be non-empty when a draft is requested.
- requestedArticleTypes must contain at least one valid article type.
- name: DraftArticle
description: >
One drafted article entry, the per-article unit of the review handoff
(Q2=A). Addressable by stable articleId so the review page can approve /
edit / replace it independently (US9/AC9.1.5).
attributes:
- name: articleId
type: string
required: true
unique: true
description: Stable per-article identifier.
- name: type
type: string
required: true
allowed_values: [lead, article, filler, wellwish]
description: Article kind.
- name: headline
type: string
required: true
description: Draft headline.
- name: byline
type: string
required: false
default: ""
description: Draft byline.
- name: body
type: string
required: true
description: Draft body text.
- name: section
type: string
required: false
description: Newspaper section the draft belongs to.
- name: maxLength
type: int
required: true
description: Guardrail - max length for this article type (Q5=A).
entity_constraints:
- articleId must be unique within a DraftBundle.
- body length must not exceed the type's maxLength guardrail.
- name: DraftBundle
description: >
The per-article review handoff produced by ai-draft (Contract 5). One
entry per drafted article; the review page displays each and lets the
couple approve / edit / replace it (US9).
attributes:
- name: draftBundleId
type: string
required: true
unique: true
description: Stable bundle identifier.
- name: articles
type: list[DraftArticle]
required: true
description: The drafted articles in the bundle.
- name: generatedAt
type: datetime
required: true
description: When the draft was generated.
- name: status
type: string
required: true
allowed_values: [drafted, empty, partial_error]
description: >
empty means the model was unreachable/failed and no draft was produced
(Q3=A); the generator proceeds without AI.
relationships: []
entity_constraints:
- articles must each carry a unique articleId.
- status=empty must carry a statusMessage describing the failure.
- name: StatusMessage
description: Human-readable failure/status note attached when the draft is empty or partial (Q3=A).
attributes:
- name: code
type: string
required: true
description: Stable status code (e.g. MODEL_UNAVAILABLE).
- name: message
type: string
required: true
description: Plain-language explanation for the user.
entity_constraints: []
```
## Human-readable entity summary
- **DraftBrief** — the compact, content-derived brief (couple names, occasion/date,
key themes) handed to the cloud model to make drafts feel on-theme (Q4=A).
- **DraftArticle** — the per-article draft unit with a stable `articleId`; the
review page's approve/edit/replace surface (US9). Enforces a light max-length
guardrail per article type (Q5=A).
- **DraftBundle** — the review handoff container (one article per entry) with a
status; `empty` signals model failure and the generator proceeds without AI
(Q3=A).
- **StatusMessage** — the clear failure note a user sees when no draft is produced.
No cross-unit entity relationships owned here (DraftBundle/Article are ai-draft-owned;
Generator consumes them via Contract 1/5 as a data artifact).
@@ -0,0 +1,78 @@
# Functional Design — Questions (unit: ai-draft)
> Fill in each `[Answer]:` tag. Options A-E plus X (Other). The file is the
> authoritative record of the ai-draft unit's functional design decisions.
## Q1: Draft orchestration
The `ai-draft` unit drafts newspaper copy via the granted cloud model (`deepseek-v4-flash:cloud`) as an optional step. How should the draft flow work end-to-end?
A) One call per article kind — the generator asks ai-draft for a draft, ai-draft prepares a brief from content-derived context, calls the model once per requested article type, and returns a DraftBundle for per-article review (recommended)
B) One single call for the whole newspaper — the model writes all articles in one response, ai-draft splits them into a DraftBundle
C) Streamed incremental drafting — ai-draft drafts and refines iteratively before returning
X) Other (please specify)
[Answer]: A
## Q2: DraftBundle content & review shape
The `DraftBundle` is the per-article review handoff (Contract 5). What should it carry so the review page can let you approve / edit / replace each article (US9)?
A) Each drafted article as its own entry with a stable articleId, type, headline, byline, body, and section — one entry per article the review page displays (recommended)
B) A single blob of draft text plus per-article markers the review page parses
C) The raw model response plus structured metadata per article
X) Other (please specify)
[Answer]: A
## Q3: Model failure handling
When the cloud model is unreachable or fails mid-draft (Q4=A in contracts: fail gracefully), what should ai-draft do?
A) Return an empty/no-draft DraftBundle with a clear status message; the generator proceeds without AI (recommended)
B) Retry the model a couple of times, then return empty with a message
C) Return partial drafts for the articles that succeeded, with a note for the missing ones
X) Other (please specify)
[Answer]: A
## Q4: Draft input & content context
What should drive what the model writes (the "content-derived context" that makes drafts feel on-theme)?
A) A concise brief built by ai-draft from the user's content: the couple's names, the occasion/date, and key themes pulled from the content files (recommended)
B) The raw content files passed verbatim to the model
C) A hand-written brief the user supplies alongside --draft
X) Other (please specify)
[Answer]: A
## Q5: Draft length & tone guardrails
Should the unit enforce basic output constraints on the drafted copy?
A) Yes — light guardrails: a max length per article type and an instruction to keep tone appropriate for a wedding keepsake (recommended)
B) No — passthrough, whatever the model returns is kept
C) Strict validation — reject drafts that don't match a specified structure/length
X) Other (please specify)
[Answer]: A
## Consolidated Summary Confirmation
> Summary of the five ai-draft functional-design answers before the unit artifacts are generated:
>
> - Draft orchestration: **one model call per article kind** → ai-draft builds a brief, calls the model per requested type, returns a DraftBundle (Q1=A)
> - DraftBundle: **each drafted article as its own entry** with stable articleId, type, headline, byline, body, section (Q2=A)
> - Model failure: **empty/no-draft DraftBundle with a status message**; generator proceeds without AI (Q3=A)
> - Draft context: **concise brief** from couple names, occasion/date, key themes pulled from content (Q4=A)
> - Guardrails: **light** — max length per article type + wedding-appropriate tone instruction (Q5=A)
>
> Human auto-approved this summary (answers read from the file; explicit permission granted).
Does this all look correct before I generate the unit artifacts?
- Looks correct
- Request changes
[Answer]: Looks correct
@@ -0,0 +1,89 @@
# Functional Design — Functional Specification (unit: ai-draft)
> Behavioural specification for the `ai-draft` library unit — the source of
> truth for its workflows and state transitions. Per confirmed answers: one
> model call per requested article type, per-article addressable DraftBundle,
> empty-no-draft on model failure, brief-driven context, light guardrails.
## Context
`ai-draft` is the optional cloud-model drafting library. When the couple runs
`newspaper generate --draft`, the generator (U1) invokes this unit; when `--draft`
is absent, ai-draft does nothing (BR1.1). It drafts newspaper copy per requested
article type via the granted cloud model `deepseek-v4-flash:cloud` and returns a
`DraftBundle` the per-article review page consumes (US9).
## Workflow: draft one newspaper (from generator)
1. The generator requests a draft with `--draft`, passing a `DraftBrief`-shaped
request (RequestedArticleTypes + content-derived context).
2. ai-draft prepares a concise brief: couple names, occasion/date, key themes
pulled from the content files (Q4=A, BR2.2), plus a keepsake-appropriate tone
instruction and per-type max-length note (Q5=A, BR3.2, BR3.1).
3. For each requested article type (one at a time), ai-draft calls the granted
cloud model once and receives a DraftArticle-shaped response (Q1=A, BR1.2).
4. ai-draft assembles the articles into a DraftBundle, each article carrying a
stable articleId, type, headline, byline, body, section (Q2=A, BR1.3).
5. ai-draft returns the DraftBundle (status=drafted) to the generator, which
writes it as the review-handoff JSON (Contract 5).
## State transitions — DraftRequest
```
IDLE --generator requests draft--> DRAFTING
DRAFTING --model ok, all types drafted--> DRAFTED
DRAFTING --model failed/unreachable anywhere--> EMPTY (StatusMessage set, BR2.1)
DRAFTED --bundle written--> REVIEW_HANDOFF (consumed by review page)
```
- **IDLE** — ai-draft not active (default; no `--draft`).
- **DRAFTING** — building briefs and calling the model per requested type.
- **DRAFTED** — all requested types produced; DraftBundle status=drafted.
- **EMPTY** — the model failed/unreachable during drafting; DraftBundle status=empty
with a StatusMessage; the generator proceeds without AI (BR2.1, Q3=A). Never a
crash/blank.
## State transitions — DraftArticle (per article in the bundle)
```
DRAFTED --review page approve--> APPROVED
DRAFTED --review page edit--> REVISING --> APPROVED (per-article, US9)
DRAFTED --review page replace--> REPLACED (per-article, US9)
```
Each article is its own addressable unit (BR1.3); approving one never changes
another (US9/AC9.1.2–3).
## Error handling & edge cases
- **Model unreachable/failure** → status=empty + StatusMessage (BR2.1); builder
continues without AI. Graceful, not a crash.
- **Requested type unknown** → skipped with a note in the StatusMessage; no
invalid draft shipped.
- **No content / blank brief** → brief falls back to a light default (BR2.2);
still drafts, on-theme guidance weaker.
- **Body over max-length** → trimmed/flagged (BR3.1); never shipped over-length.
## Derived: ER diagram (from entities.md)
```mermaid
erDiagram
DraftBrief ||--o{ DraftArticle : "requests a type"
DraftBundle ||--|{ DraftArticle : "holds (one per article)"
DraftBundle ||--o| StatusMessage : "on empty/partial (Q3=A)"
```
## Derived: rules summary (from rules.md)
| ID | Rule (one line) |
|---|---|
| BR1.1 | Draft only when requested; else no-op |
| BR1.2 | One model call per requested article type |
| BR1.3 | Each article is an addressable per-article unit |
| BR2.1 | Model failure → empty DraftBundle + StatusMessage, generator proceeds |
| BR2.2 | On-theme via content-derived brief |
| BR3.1 | Light max-length guardrail per type |
| BR3.2 | Wedding-appropriate tone instruction |
| BR3.3 | Only the granted cloud model; never leak content |
<!-- functional-design ai-draft after-confirm -->
@@ -0,0 +1,16 @@
<!-- INVARIANT: examples are single-line HTML comments so a fresh template parses to total=0 (MEMORY_EMPTY). Do NOT un-comment or split across lines. t100 guards this. -->
> This file is kept up to date automatically while the stage runs. Add observations at the review step, not by editing here directly.
## Interpretations
<!-- example: 2026-05-29T10:14:32Z — chose REST over GraphQL; the consuming team only needs CRUD, revisit if subscriptions land -->
## Deviations
<!-- example: 2026-05-29T10:14:32Z — skipped the optional caching layer the stage prose suggested; the dataset is small enough that it adds risk -->
## Tradeoffs
<!-- example: 2026-05-29T10:14:32Z — picked TDD over BDD this run; the team is unit-first and the domain is well-understood -->
## Open questions
<!-- example: 2026-05-29T10:14:32Z — confirm the retention window with compliance before the next stage hardens the schema -->
2026-09-13T12:50:00Z — Interpretation — Functional design for ai-draft unit generated per confirmed answers: one call per article type, per-article addressable DraftBundle/Article, empty+DraftBundle on model failure, brief-driven content context, light guardrails
2026-09-13T12:50:00Z — Tradeoff — DraftArticle enforces a max-length guardrail (BR3.1) internally; trimmed/flagged rather than shipped over-length
@@ -0,0 +1,94 @@
# Functional Design — Business Rules (unit: ai-draft)
> Numbered business rules for the `ai-draft` library unit. Source of truth in
> the fenced YAML; human summary below. Per confirmed answers: one call per
> article kind, per-article addressable DraftBundle, empty-no-draft on model
> failure, brief-driven context, light guardrails.
```yaml
rules:
- id: BR1.1
statement: Only draft when requested.
category: constraint
applies_to: ai-draft
trigger: generator invokes ai-draft
logic: IF the generator asks for a draft THEN ai-draft runs; IF --draft is absent THEN ai-draft does nothing.
violation: No draft is produced; the paper builds without AI.
source: FR8.1, US8
- id: BR1.2
statement: Draft one article per requested type.
category: calculation
applies_to: ai-draft
trigger: a draft is requested with one or more article types
logic: IF requestedArticleTypes = T1,T2 THEN ai-draft builds a brief and calls the model once per type, returning one DraftArticle per type.
violation: not applicable
source: Q1=A, US8
- id: BR1.3
statement: Draft the article only as an addressable per-article unit.
category: constraint
applies_to: ai-draft
trigger: DraftBundle is built
logic: IF a draft is produced THEN each article is its own DraftArticle with a unique articleId, headline, byline, body, section; the review page addresses them individually.
violation: Per-article approve/edit/replace (US9) cannot be delivered.
source: Q2=A, US9, Contract 5
- id: BR2.1
statement: Fail gracefully when the cloud model is unreachable or errors.
category: constraint
applies_to: ai-draft
trigger: model call fails or returns an error
logic: IF the model is unreachable OR fails THEN ai-draft returns a DraftBundle with status=empty and a clear StatusMessage; it never throws to the generator.
violation: The generator still builds the paper (without AI); no blank/crash page is produced.
source: Q3=A, Contract 4 (graceful failure)
- id: BR2.2
statement: Keep the draft on-theme via content-derived context.
category: policy
applies_to: ai-draft
trigger: a draft brief is built
logic: IF content is available THEN the brief includes the couple's names, the occasion/date, and key themes pulled from the content files; IF no content THEN the brief asks the model for a light default.
violation: Drafts may feel generic or off-theme.
source: Q4=A, US8
- id: BR3.1
statement: Enforce a light max-length guardrail per article type.
category: validation
applies_to: ai-draft
trigger: DraftArticle body is produced
logic: IF body length exceeds the type's maxLength THEN the article is trimmed/flagged; it is never silently shipped over-length.
violation: The draft is clamped to an acceptable keepsake length.
source: Q5=A, US8
- id: BR3.2
statement: Keep tone appropriate for a wedding keepsake.
category: policy
applies_to: ai-draft
trigger: the brief is built
logic: IF a draft is requested THEN the model is instructed to keep a warm, appropriate, keepsake-appropriate tone.
violation: Draft may feel off-tone for the occasion.
source: Q5=A, US8
- id: BR3.3
statement: Never send content to any service other than the granted model.
category: authorization
applies_to: ai-draft
trigger: any external call
logic: IF a draft is requested THEN the only external call is the granted cloud model (deepseek-v4-flash:cloud) for the draft text; content stays on-machine otherwise.
violation: Content could leak off-machine.
source: NFR9, FR8.3
```
## Rules summary
| ID | Rule | Category | Source |
|---|---|---|---|
| BR1.1 | Draft only when requested (else no-op) | constraint | FR8.1, US8 |
| BR1.2 | One model call per requested article type | calculation | Q1=A, US8 |
| BR1.3 | Each article is a per-article addressable unit | constraint | Q2=A, US9, C5 |
| BR2.1 | Model failure → empty DraftBundle + StatusMessage, generator proceeds | constraint | Q3=A, C4 |
| BR2.2 | Draft on-theme via content-derived brief | policy | Q4=A, US8 |
| BR3.1 | Light max-length guardrail per type | validation | Q5=A, US8 |
| BR3.2 | Wedding-appropriate tone instruction | policy | Q5=A, US8 |
| BR3.3 | Only the granted model; never leak content | authorization | NFR9, FR8.3 |
@@ -0,0 +1,19 @@
{
"stage": "functional-design",
"unit": "ai-draft",
"upstream_ids": ["AC8.1.1", "AC8.1.2", "AC8.1.3", "AC9.1.1", "AC9.1.2", "AC9.1.3", "AC9.1.4", "AC9.1.5"],
"coverage": [
{ "id": "AC8.1.1", "status": "OK", "target": "BR1.1, BR1.2, BR2.2" },
{ "id": "AC8.1.2", "status": "OK", "target": "BR1.3" },
{ "id": "AC8.1.3", "status": "OK", "target": "BR3.3" },
{ "id": "AC9.1.1", "status": "OK", "target": "BR1.1, BR2.1" },
{ "id": "AC9.1.2", "status": "OK", "target": "BR1.3" },
{ "id": "AC9.1.3", "status": "OK", "target": "BR1.3" },
{ "id": "AC9.1.4", "status": "OK", "target": "BR1.3" },
{ "id": "AC9.1.5", "status": "OK", "target": "BR1.3, BR2.1" }
],
"reverse": [
{ "id": "BR3.1", "status": "N/A", "target": "internal guardrail validation rule" },
{ "id": "BR3.2", "status": "N/A", "target": "internal tone policy" }
]
}