Skip to content

feat(ai): AI writing assistance across composer, lists and documents (G15) - #43

Merged
Adron merged 1 commit into
mainfrom
feat/ai-writing-assistance
Sep 6, 2026
Merged

feat(ai): AI writing assistance across composer, lists and documents (G15)#43
Adron merged 1 commit into
mainfrom
feat/ai-writing-assistance

Conversation

@Adron

@Adron Adron commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

Closes G15 from work-consolidation.md. The backend shipped /api/ai/{status,suggest,generate} — five documented, Bearer-ready, subscriber-included features driving three web UIs — and iOS had no consumer at all. This adds all five (Thread B, slices B1–B5).

Gating is one flag: aiStatus().subscriber. AI runs on the app's own server-side provider key, so there is no key entry, no provider picker, and no BYO-key state to model. A free user sees no AI control at all — never a disabled one, and no price or upgrade copy anywhere (Guideline 3.1.1). providers / defaultModels are display metadata only, never a precondition.

What's included

All in commit 51d2d42.

B1 · Foundation

  • APIClient+AI.swiftaiStatus / aiSuggest / aiGenerate. This is the one feature that deliberately bypasses the shared transport seam from refactor(api): extract the HTTP seam so endpoints can live in feature files #38, for two reasons: its responses carry model-authored JSON that must reach /generate byte-identical (the shared decoder's convertFromSnakeCase would rewrite a Powered Template's DSL field keys — scheduled_atscheduledAt — and fail the server's re-validation), and checkResponse maps failures onto APIError, dropping the machine code and Retry-After that the AI error contract is built on.
  • AIJSON — lossless JSON carrier. Whole numbers stay integers, so a validation: { min: 1 } doesn't come back as 1.0.
  • AIServiceError — all seven documented codes. Per the entitlement decision, no_provider_configured is a transient server outage, not a user-fixable state: neutral copy, no key prompt, control retires for the session. The subscription_required body steers toward a purchase and is never surfaced.
  • AIService — owns availability and the shared 50/day quota (both suggest and generate decrement it). The local customerStatus gates the first render so nothing flashes; a loaded /status supersedes it. Concurrent status loads from the three entry points share one request.

B2 · Composer Writing Assistant — pen menu (rewrite / tighten / expand / grammar / thread / tags) → suggestion sheet with Replace draft / Use in draft / Add tags. Tags merge case-insensitively into the tags field.

B3 · Message + Article Series — inert under 10 words (a short draft is rejected server-side but still spends a quota unit). Rotating loading captions, because these calls run tens of seconds and a silent spinner reads as hung. Message Series reads the composer's live cross-post selection rather than offering a second picker: it sizes the posts to the tightest selected service and, with scheduleImmediately, posts to exactly those accounts.

B4 · Powered Templates — an "AI Template" tab in CreateListView.

B5 · Powered DocumentDocumentsView entry point, four modes (article / from list / from article / research URL), sending id-only references; the server re-fetches and authorizes each source itself.

Judgment calls worth a look

  • B4 creates through /generate, not the web's route (schema editor → POST /api/lists). DraftProperty only models text/number/boolean/date/url/email, so routing the AI schema through it would silently drop the generated select/multiselect columns, their options, and the starter rows. Trade-off: no inline schema editing before create (you edit after), and the list is created private.
  • Powered Document is root-only — that's where the server writes it (folderId: null), so offering it inside a folder would be misleading.
  • The writing assistant is composer-only, not replies — it lives in advancedToolbar, which replies don't render.
  • CreateListView.onCreate widens to (UserList?)/generate returns only an id, so nil means "created, refetch". The single call site was already { _ in } and is unchanged.
  • MockURLSession gained optional response headers, needed to test Retry-After.
  • Docs (work-consolidation.md / the-gaps.md) are untouched — Thread A owns them.

Testing

  • xcodebuild test -scheme InterlinedList -destination 'platform=iOS Simulator,id=302E002E-…' -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests942 tests, 0 failures (861 on this base plus 81 new).
  • Rebased onto current main (after refactor(api): extract the HTTP seam so endpoints can live in feature files #38docs: record Thread A as complete and the two hazards it turned up #42) and re-verified there; the private-removal seam I had hand-made was dropped in favour of refactor(api): extract the HTTP seam so endpoints can live in feature files #38's APIClientTransport.swift.
  • New coverage: the full §5.1 error table incl. Retry-After; a Powered Template DSL round-trip asserting field keys survive suggest → generate; a Guideline 3.1.1 test asserting "subscription"/"subscribe"/"upgrade" cannot reach the UI; quota accounting; the cross-post → channel-label → char-limit derivation.
  • Live check: a new read-only E2E test pins the production GET /api/ai/status payload to the client model and asserts its subscriber matches the local customerStatus the composer gates on. Passed against the .env account. /suggest and /generate are deliberately not exercised in E2E — both spend the account's daily quota and /generate writes.

🤖 Generated with Claude Code

The backend shipped `/api/ai/{status,suggest,generate}` — five documented,
Bearer-ready, subscriber-included features driving three web UIs — and iOS had
no consumer at all (work-consolidation.md G15). This adds all five.

Gating is one flag, `aiStatus().subscriber`. AI runs on the app's own
server-side provider key, so there is no key entry, no provider picker, and no
BYO-key state to model. A free user sees no AI control at all — never a disabled
one, and no price or upgrade copy anywhere (Guideline 3.1.1). `providers` /
`defaultModels` are display metadata only, never a precondition.

- `APIClient+AI.swift` is the one feature that deliberately bypasses the shared
  transport seam. Its responses carry model-authored JSON that must reach
  `/generate` byte-identical — the shared decoder's `convertFromSnakeCase` would
  rewrite a Powered Template's DSL field keys (`scheduled_at` → `scheduledAt`)
  and fail the server's re-validation — and `checkResponse` maps failures onto
  `APIError`, dropping the machine `code` and `Retry-After` that the AI error
  contract is built on. Plain coders and `AIServiceError` instead
- `AIJSON` carries that JSON losslessly (whole numbers stay integers, so a
  `validation: { min: 1 }` doesn't come back as `1.0`)
- `AIServiceError` maps all seven documented codes. Per the product decision
  that AI is an entitlement, `no_provider_configured` is now a transient server
  outage, not a user-fixable state: neutral copy, no key prompt, control retires
  for the session. The `subscription_required` body steers toward a purchase and
  is never surfaced — a test asserts the words can't reach the UI
- `AIService` owns availability and the shared 50/day quota (both `suggest` and
  `generate` decrement it). The local `customerStatus` gates the first render so
  nothing flashes; a loaded `/status` supersedes it. Concurrent status loads from
  the three entry points share one request
- Composer: pen menu (rewrite / tighten / expand / grammar / thread / tags) →
  suggestion sheet with Replace draft / Use in draft / Add tags, tags merging
  case-insensitively into the tags field
- Composer: Message + Article Series, inert under 10 words (a short draft is
  rejected server-side but still spends a quota unit). Rotating loading captions
  because these calls run tens of seconds. Message Series reads the composer's
  live cross-post selection rather than offering a second picker — it sizes the
  posts to the tightest selected service and, with `scheduleImmediately`, posts
  to exactly those accounts
- `CreateListView` grows an "AI Template" tab. It creates through `/generate`
  rather than the web's route via the schema editor, because `DraftProperty`
  only models text/number/boolean/date/url/email and would silently drop the
  generated `select`/`multiselect` columns, their options, and the starter rows
- `DocumentsView` grows Powered Document (article / from list / from article /
  research URL), sending id-only references — the server re-fetches and
  authorizes each source itself. Root-only, matching where the server writes it
- `CreateListView.onCreate` widens to `(UserList?)`: `/generate` returns only an
  id, so nil means "created, refetch". The single call site is unchanged
- `MockURLSession` gained optional response headers, needed to test `Retry-After`

Verified: build succeeds; 942 tests, 0 failures (iPhone 16 · 302E002E-…,
-parallel-testing-enabled NO, E2E skipped) — 861 on this base plus 81 new. A new
read-only E2E test pins the live production `/api/ai/status` payload to the
client model and asserts its `subscriber` matches the local `customerStatus` the
composer gates on; it passed against the account in `.env`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K42aNvtDc3mSRceuTLohj9
@Adron
Adron merged commit 625a19b into main Sep 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant