feat(ai): :feature:ai scaffold — status, gating, quota and preview→confirm (#4) - #92
Merged
Merged
Conversation
…firm Adds the self-contained :feature:ai module the AI surfaces (#5–#9) build on: its own AiApi over GET /api/ai/status, POST /api/ai/suggest and POST /api/ai/generate off the shared authed Retrofit, defensive DTOs, and a repository exposing availability(), suggest() and generate(). Gating lives in one place: AiGate publishes an AiAvailability that is only Available for a subscriber on a deployment with a provider configured, so no AI control is ever drawn for a free account. The subscriber flag comes from /api/ai/status, falling back to the same customerStatus the rest of the app gates on when the body omits it; an unverifiable subscription hides AI rather than guessing. Preview→confirm is enforced by the type system: /generate only accepts a ConfirmedPreview, whose constructor is module-private and is reachable only via AiPreview.confirm(), and AiPreviewSession drives the shared state machine so the rule is not re-implemented five times. Failures map off the API's `code` (not just the status) so quota_exceeded, rate_limited and no_provider_configured stay distinct, with the quota case surfacing as "Daily AI limit reached". No UI, navigation entries or menu items — :app depends on the module only so its Hilt bindings join the component. Closes #4
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 #4. Part of epic #3. Foundation for #5, #6, #7, #8, #9.
What this is
A new self-contained
:feature:aimodule: the API layer, gating and preview→confirm contract thatthe five AI feature issues will build on. No UI, no navigation, no menu entries — those ship
with the features themselves.
The contract came from the docs, not from guesses
The OpenAPI spec models all three AI routes as a bare
{"type":"object"}, so it was useless forshapes. The real reference is published at
https://interlinedlist.com/help/api/ai-integration,and every field, error
codeand limit here came from it.I have since confirmed
GET /api/ai/statusagainst the live API with a real subscriber token,and it matches exactly:
{ "subscriber": true, "providers": ["anthropic"], "defaultModels": { "anthropic": "claude-sonnet-5", "openai": "gpt-4.1-mini", "gemini": "gemini-2.0-flash" }, "quota": { "usedToday": 0, "dailyLimit": 50, "remaining": 50 } }So — answering the open question in the issue —
/api/ai/statusdoes expose quota(
usedToday/dailyLimit/remaining), and/suggestand/generateecho the same object.Nothing was invented.
Design
AiFeatureis the singlefeaturediscriminator, carrying all five documented wire values(
writing_assist,powered_template,powered_document,message_series,article_series).Siblings reference a case rather than adding one.
AiGate(@Singleton) exposesStateFlow<AiAvailability>ofUnknown / Unavailable / NotSubscribed / Available(quota). OnlyAvailableenables a control, soa free account never sees one. Empty
providershides AI for everyone; an absentprovidersfield is deliberately not treated as the same claim; a subscription that cannot be confirmed hides
AI rather than guessing. Gating reuses the app's existing model —
/status's ownsubscriberflag first, falling back to
getCurrentUser()→CustomerStatus.isSubscriberfrom:core:model,the same field
:feature:profilegates on.generate()accepts only aConfirmedPreview, whose constructor is module-private and reachable only viaAiPreview.confirm(edited). So "nothing is written until the user approves" cannot be bypassed bya careless call site.
AiPreviewSession+AiPreviewStategive all five surfaces one state machine.code(falling back to status), keepingquota_exceeded,rate_limited(withRetry-After) andno_provider_configureddistinct.Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, 744 tests repo-wide, 0failures (58 new). Repository tests run a real Retrofit/OkHttp stack against MockWebServer,
asserting actual request paths and bodies. The gating logic was mutation-checked — flipping
!subscribertoAvailablemakes two tests fail, so the subscriber gate is genuinely covered.Outside the module the only edits are one line in
settings.gradle.ktsand oneimplementation(project(":feature:ai"))inapp/build.gradle.kts(so the Hilt bindings join thecomponent). No
libs.versions.tomlchange was needed.Reviewer notes — deliberate calls worth a look
AiError/AiResultare module-local rather than the sharedAppError/ApiResult. Theshared type drops the
codefield and so cannot tell the two different 429s apart, norno_provider_configuredfrom a generic conflict. Editing:core:commonwas out of scope. Thisis the one intentional duplication — say the word and it can be promoted instead.
feature-module shape and so five parallel branches don't each have to edit it. No shared
preview-sheet Composable was built: with no consumer yet and no emulator to test it, the state
contract is the honest deliverable.
JsonObjects, round-tripped verbatim to/generate; each sibling issuedecodes its own typed shape.
AiGate.ensureResolved()has no mutex — two surfaces racing at startup cost one extraGET /api/ai/status./suggestand/generaterequest/response shapes are from the published docs, not acaptured live response. First real exercise is AI: writing assistant in the message composer (rewrite / tighten / expand / fix grammar / make a thread / suggest tags) #5.