Skip to content

feat(documents): AI Powered Document — four draft modes with preview→confirm (#9) - #117

Merged
Adron merged 1 commit into
parity/queuefrom
issue/9-ai-powered-document
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/9-ai-powered-document

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #9. Part of epic #3. Builds on #4's :feature:ai scaffold.

⚠️ One decision needs your ruling: the first feature→feature dependency

This PR adds :feature:documents:feature:ai. There is no way to use #4's
AiRepository / AiGate / ConfirmedPreview without it.

The argument for allowing it: :feature:ai is a leaf capability module — it depends only on
:core:*, owns no navigation and no screens, and #4 built it explicitly to be consumed
(app/build.gradle.kts already says "the AI surfaces themselves live in the feature modules that
use them"). No feature→feature surface dependency was created, and in particular no
documents→lists edge
.

The argument against: the repo's convention to date is that no feature module depends on another,
which is what has kept parallel work conflict-free. If you would rather :feature:ai were
:core:ai, now is the cheap moment to say so
— four more AI issues (#5, #6, #7, #8) will each
want the same edge.

What was built

A Powered Document entry point on the Documents surface drafting a full markdown document in all
four modes, sent as context.mode on POST /api/ai/suggest: article, from_list
(context.listId), from_article (context.documentId), research_url (context.url). Only a
reference is ever sent
— the server resolves the source itself.

#4's preview→confirm contract is reused, not re-created. AiPreviewSession runs /suggest, and
/generate is reachable only through a ConfirmedPreview that only an on-screen preview can mint —
so backing out is structurally incapable of writing. The preview allows editing the title; every
other artifact key, including unmodelled ones, round-trips untouched.

Gating: AiGate is the only check. The control is drawn only when GET /api/ai/status resolves
to Available, so a free account, an unreadable status, and a deployment with no provider configured
all see nothing.

Quota is not spent needlessly — a rejected call still burns one of the 50/day, so an invalid
Research URL (http/https + real host), a missing derived source, and an instruction over this
feature's 500-word cap are all refused locally. Errors keep per-code wording:
quota_exceeded reads "Daily AI limit reached. Try again tomorrow."; rate_limited carries its
Retry-After.

Source pickers

  • Documents: reused DocumentSearchOverlay from ui.browser as-is — the module's existing picker.
  • Lists: the lists browser lives in :feature:lists, which was not imported. Instead
    :feature:documents owns a one-endpoint read-only /api/lists client feeding a small in-module
    dialog. One GET, no cache, no schema, no rows — consistent with "each feature owns its own Retrofit
    interface over the shared authed Retrofit".

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1190 tests, 0 failures. 24
new (21 ViewModel + 3 validator): one per mode asserting selection → suggest with the exact
context sent
→ confirm; zero generate calls after backing out; no control and no request on
a free/unavailable account; URL refusal before any request; quota_exceeded surfacing as the
daily-limit message on both legs. 2 Compose assertions compile, not executed (no emulator).

Other notes

  • Deliberately avoided combine(...).stateIn(...) for the UI state. It made uiState.value lag
    synchronous form setters by one dispatcher turn — a real trap for callers. A single
    MutableStateFlow with the gate and preview collected into it behaves correctly and matches the
    rest of the module.
  • The list picker shows one page (limit=100) with no paging; a user with more lists than that would
    not see them all. Paging felt unjustified for a source picker, but it is the obvious follow-up.

…saving

The Documents surface had no AI entry point at all. `powered_document` now drafts
a full markdown document in each of the four modes the web app offers, set as
`context.mode` on POST /api/ai/suggest:

  - article       - a standalone piece from a topic the user describes.
  - from_list     - `context.listId`, a write-up of one of the user's lists.
  - from_article  - `context.documentId`, a new document from an existing one.
  - research_url  - `context.url`, a document researched from a web page.

Only a reference is ever sent. The server resolves the source itself, under the
owning user (IDOR-guarded) and through its SSRF-guarded fetcher, so the client
never ships list rows or page text.

Nothing is written until the user says so. The flow reuses the preview -> confirm
contract #4 landed rather than re-implementing it: AiPreviewSession runs
/suggest, and /generate is reachable only through a ConfirmedPreview, which only
an on-screen preview can mint. Backing out is therefore incapable of writing -
it is a property of the types, not of this screen remembering to check. The
preview lets the title be edited; the rest of the artifact envelope (including
keys this client does not model) round-trips to /generate untouched.

Source pickers, and why they look the way they do. No feature module in this
repo depends on another, and that stays true here:

  - The document picker is DocumentSearchOverlay, the browser's own search
    picker, reused as-is from ui.browser rather than reimplemented.
  - The lists browser lives in :feature:lists, which this module must not
    import, so the list source is read through a one-endpoint, read-only
    /api/lists client owned by this module (ListSourcesApi/ListSourcesRepository)
    and shown in a small in-module dialog. Each feature owning its own Retrofit
    interface over the shared authed Retrofit is the established convention;
    this is one GET, no cache, no schema, no rows.

:feature:documents does now depend on :feature:ai. That module is a leaf - it
depends only on :core:*, owns no navigation, and was built by #4 to be consumed
("the AI surfaces themselves live in the feature modules that use them"). It is
a capability module, not a peer surface, and it is the only feature dependency
added.

Gating and quota. AiGate is the single check: the control is drawn only when
GET /api/ai/status resolves to Available, so a free account, an account whose
status could not be read, and a deployment with no ANTHROPIC_API_KEY all see no
control at all rather than one that 403s. Remaining daily actions are shown from
the same gate, refreshed from the quota /suggest and /generate echo back.

Spending a generation is avoided where it can be. A rejected call still burns one
of the 50 per day, so the Research URL is checked locally first (http/https only,
a real host - matching what the server's fetcher would accept), a derived mode
with no source picked issues nothing, and an instruction over the 500-word cap
for this feature is refused before the request. Errors keep their own wording per
`code`: quota_exceeded reads "Daily AI limit reached. Try again tomorrow." and
rate_limited carries its Retry-After seconds, so the two 429s never blur into one
generic failure.

A saved draft pulls the tree so the new document is in the cache, then replaces
this screen with the editor.

Tests: one ViewModel test per mode, each asserting source selection -> suggest
(with the exact context sent) -> confirm, and that the suggestion alone wrote
nothing; backing out of a preview issues zero generate calls, as does confirming
afterwards; a free account and an unconfigured deployment both see no control and
can issue nothing, on this surface and on the browser; an invalid and a non-http
Research URL are both refused before any request; quota_exceeded surfaces as the
daily-limit message on both legs; the edited title reaches the artifact while the
markdown survives; plus the URL validator and Compose coverage of the gated
control (instrumented, not executed here - no emulator available).

Closes #9

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit fbaab10 into parity/queue Sep 16, 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