feat(documents): AI Powered Document — four draft modes with preview→confirm (#9) - #117
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #9. Part of epic #3. Builds on #4's
:feature:aiscaffold.This PR adds
:feature:documents→:feature:ai. There is no way to use #4'sAiRepository/AiGate/ConfirmedPreviewwithout it.The argument for allowing it:
:feature:aiis 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.ktsalready says "the AI surfaces themselves live in the feature modules thatuse 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:aiwere:core:ai, now is the cheap moment to say so — four more AI issues (#5, #6, #7, #8) will eachwant 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.modeonPOST /api/ai/suggest:article,from_list(
context.listId),from_article(context.documentId),research_url(context.url). Only areference is ever sent — the server resolves the source itself.
#4's preview→confirm contract is reused, not re-created.
AiPreviewSessionruns/suggest, and/generateis reachable only through aConfirmedPreviewthat 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:
AiGateis the only check. The control is drawn only whenGET /api/ai/statusresolvesto
Available, so a free account, an unreadable status, and a deployment with no provider configuredall 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-
codewording:quota_exceededreads "Daily AI limit reached. Try again tomorrow.";rate_limitedcarries itsRetry-After.Source pickers
DocumentSearchOverlayfromui.browseras-is — the module's existing picker.:feature:lists, which was not imported. Instead:feature:documentsowns a one-endpoint read-only/api/listsclient feeding a small in-moduledialog. 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. 24new (21 ViewModel + 3 validator): one per mode asserting selection → suggest with the exact
contextsent → confirm; zerogeneratecalls after backing out; no control and no request ona free/unavailable account; URL refusal before any request;
quota_exceededsurfacing as thedaily-limit message on both legs. 2 Compose assertions compile, not executed (no emulator).
Other notes
combine(...).stateIn(...)for the UI state. It madeuiState.valuelagsynchronous form setters by one dispatcher turn — a real trap for callers. A single
MutableStateFlowwith the gate and preview collected into it behaves correctly and matches therest of the module.
limit=100) with no paging; a user with more lists than that wouldnot see them all. Paging felt unjustified for a source picker, but it is the obvious follow-up.