Skip to content

Commit 3876d6b

Browse files
committed
merge: #4 :feature:ai scaffold (PR #92)
2 parents f382542 + 7d95cd7 commit 3876d6b

34 files changed

Lines changed: 2079 additions & 0 deletions

‎app/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ dependencies {
5757
implementation(project(":feature:notifications"))
5858
implementation(project(":feature:organizations"))
5959
implementation(project(":feature:integrations"))
60+
// Depended on so its Hilt modules join the app component. The AI surfaces
61+
// themselves live in the feature modules that use them, so there is no
62+
// navigation entry here.
63+
implementation(project(":feature:ai"))
6064

6165
// Compose
6266
implementation(platform(libs.androidx.compose.bom))

‎feature/ai/build.gradle.kts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
plugins {
2+
alias(libs.plugins.android.library)
3+
alias(libs.plugins.kotlin.android)
4+
alias(libs.plugins.kotlin.compose)
5+
alias(libs.plugins.kotlin.serialization)
6+
alias(libs.plugins.ksp)
7+
alias(libs.plugins.hilt)
8+
}
9+
10+
android {
11+
namespace = "com.interlinedlist.android.feature.ai"
12+
compileSdk = 35
13+
14+
defaultConfig {
15+
minSdk = 26
16+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17+
}
18+
19+
buildFeatures { compose = true }
20+
21+
compileOptions {
22+
sourceCompatibility = JavaVersion.VERSION_17
23+
targetCompatibility = JavaVersion.VERSION_17
24+
}
25+
kotlinOptions { jvmTarget = "17" }
26+
}
27+
28+
dependencies {
29+
implementation(project(":core:model"))
30+
implementation(project(":core:common"))
31+
implementation(project(":core:designsystem"))
32+
// The shared authed Retrofit, safeApiCall, and InterlinedListApi (used to read
33+
// `customerStatus` when /api/ai/status omits its `subscriber` flag).
34+
implementation(project(":core:network"))
35+
36+
// The AI wire format is only partly modelled: artifacts and the `context`
37+
// hint bag stay raw JsonObjects that the sibling AI surfaces decode, so the
38+
// type leaks into this module's public API and has to be `api`.
39+
api(libs.kotlinx.serialization.json)
40+
41+
// This module has no Room cache: /suggest and /generate are live, one-shot
42+
// calls and /status is a cheap read, so there is nothing worth persisting.
43+
44+
implementation(platform(libs.androidx.compose.bom))
45+
implementation(libs.androidx.compose.ui)
46+
implementation(libs.androidx.compose.material3)
47+
implementation(libs.androidx.compose.material.icons.extended)
48+
implementation(libs.androidx.compose.ui.tooling.preview)
49+
debugImplementation(libs.androidx.compose.ui.tooling)
50+
implementation(libs.androidx.lifecycle.viewmodel.compose)
51+
implementation(libs.androidx.lifecycle.runtime.compose)
52+
53+
implementation(libs.hilt.android)
54+
ksp(libs.hilt.compiler)
55+
implementation(libs.androidx.hilt.navigation.compose)
56+
57+
implementation(libs.retrofit.core)
58+
implementation(libs.okhttp.core)
59+
60+
// Unit tests
61+
testImplementation(libs.junit)
62+
testImplementation(libs.kotlinx.coroutines.test)
63+
testImplementation(libs.turbine)
64+
testImplementation(libs.truth)
65+
// Repository tests hit a MockWebServer through the real Retrofit stack.
66+
testImplementation(libs.okhttp.mockwebserver)
67+
testImplementation(libs.retrofit.core)
68+
testImplementation(libs.retrofit.kotlinx.serialization)
69+
testImplementation(libs.okhttp.core)
70+
testImplementation(libs.kotlinx.serialization.json)
71+
72+
// Instrumented / UI tests
73+
androidTestImplementation(libs.androidx.test.ext.junit)
74+
androidTestImplementation(libs.androidx.test.runner)
75+
androidTestImplementation(platform(libs.androidx.compose.bom))
76+
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
77+
androidTestImplementation(libs.truth)
78+
debugImplementation(libs.androidx.compose.ui.test.manifest)
79+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.interlinedlist.android.feature.ai.data
2+
3+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiErrorCode
4+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiErrorDto
5+
import com.interlinedlist.android.feature.ai.domain.AiError
6+
import com.interlinedlist.android.feature.ai.domain.AiResult
7+
import kotlinx.serialization.json.Json
8+
import retrofit2.HttpException
9+
import java.io.IOException
10+
11+
/**
12+
* Runs an AI call and normalises every failure into an [AiError].
13+
*
14+
* The AI routes always answer `{ "error": …, "code": … }`, so the `code` is the
15+
* primary key and the HTTP status is only the fallback for a route that omitted
16+
* it. This is the one place a `/api/ai/…` failure is interpreted.
17+
*/
18+
internal suspend fun <T> aiApiCall(json: Json, block: suspend () -> T): AiResult<T> = try {
19+
AiResult.Success(block())
20+
} catch (e: HttpException) {
21+
AiResult.Failure(e.toAiError(json))
22+
} catch (e: IOException) {
23+
AiResult.Failure(AiError.Network(e.message))
24+
} catch (e: Exception) {
25+
AiResult.Failure(AiError.Unknown(e.message))
26+
}
27+
28+
private fun HttpException.toAiError(json: Json): AiError {
29+
val body = runCatching { response()?.errorBody()?.string() }.getOrNull()
30+
val dto = body
31+
?.takeIf { it.isNotBlank() }
32+
?.let { runCatching { json.decodeFromString(AiErrorDto.serializer(), it) }.getOrNull() }
33+
val message = dto?.error
34+
val code = dto?.code
35+
// Present on the 429 rate-limit response; absent on the daily-quota one.
36+
val retryAfter = response()?.headers()?.get("Retry-After")?.trim()?.toIntOrNull()
37+
38+
return when {
39+
code == AiErrorCode.UNAUTHORIZED -> AiError.NotAuthenticated(message)
40+
code == AiErrorCode.SUBSCRIPTION_REQUIRED -> AiError.NotSubscribed(message)
41+
code == AiErrorCode.NO_PROVIDER_CONFIGURED -> AiError.ProviderUnconfigured(message)
42+
code == AiErrorCode.QUOTA_EXCEEDED -> AiError.QuotaExceeded(message)
43+
code == AiErrorCode.RATE_LIMITED -> AiError.RateLimited(message, retryAfter)
44+
code == AiErrorCode.INVALID_INPUT -> AiError.InvalidInput(message)
45+
code == AiErrorCode.INVALID_AI_OUTPUT || code == AiErrorCode.REFUSED ->
46+
AiError.InvalidOutput(message)
47+
code == AiErrorCode.PROVIDER_ERROR -> AiError.ProviderFailure(message)
48+
// A restricted/suspended/probation account is forbidden for a reason a
49+
// subscription would not fix, so it must not become an upsell.
50+
code?.startsWith(AiErrorCode.ACCOUNT_PREFIX) == true -> AiError.Forbidden(message)
51+
else -> fromStatus(code(), message, retryAfter)
52+
}
53+
}
54+
55+
/** Fallback for a response that carried no `code`. */
56+
private fun fromStatus(status: Int, message: String?, retryAfter: Int?): AiError = when (status) {
57+
401 -> AiError.NotAuthenticated(message)
58+
// The only 403 the AI routes document is the subscriber gate.
59+
403 -> AiError.NotSubscribed(message)
60+
409 -> AiError.ProviderUnconfigured(message)
61+
422 -> AiError.InvalidInput(message)
62+
// Both 429s are code-tagged in practice; `Retry-After` is what separates the
63+
// short-window limiter from the daily allowance when they are not.
64+
429 -> if (retryAfter != null) AiError.RateLimited(message, retryAfter) else AiError.QuotaExceeded(message)
65+
in 500..599 -> AiError.ProviderFailure(message)
66+
else -> AiError.Unknown(message ?: "HTTP $status")
67+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.interlinedlist.android.feature.ai.data
2+
3+
import com.interlinedlist.android.feature.ai.domain.AiAvailability
4+
import com.interlinedlist.android.feature.ai.domain.AiFeature
5+
import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions
6+
import com.interlinedlist.android.feature.ai.domain.AiGeneration
7+
import com.interlinedlist.android.feature.ai.domain.AiPreview
8+
import com.interlinedlist.android.feature.ai.domain.AiResult
9+
import com.interlinedlist.android.feature.ai.domain.AiSuggestInput
10+
import com.interlinedlist.android.feature.ai.domain.ConfirmedPreview
11+
12+
/**
13+
* The three `/api/ai/…` endpoints as one flow. Everything here is a live read or
14+
* write — nothing about an AI action is worth caching, and a stale quota would
15+
* be actively misleading.
16+
*
17+
* AI surfaces should not call [suggest]/[generate] directly unless they already
18+
* know AI is enabled; `AiGate` is the gate for that.
19+
*/
20+
interface AiRepository {
21+
22+
/**
23+
* Reads `GET /api/ai/status` and resolves whether AI may be offered at all.
24+
* Never fails: anything unreadable resolves to [AiAvailability.Unavailable]
25+
* so the AI surfaces hide instead of erroring.
26+
*/
27+
suspend fun availability(): AiAvailability
28+
29+
/**
30+
* Runs [feature] against [input] and returns a **preview**. Writes nothing —
31+
* the returned [AiPreview] must be confirmed by the user before [generate]
32+
* can be reached. Counts against the daily quota.
33+
*/
34+
suspend fun suggest(feature: AiFeature, input: AiSuggestInput): AiResult<AiPreview>
35+
36+
/**
37+
* Persists a preview the user confirmed. Takes a [ConfirmedPreview] rather
38+
* than a feature + artifact so an unapproved suggestion cannot be written;
39+
* the discriminator comes from the preview itself and cannot drift.
40+
* Counts against the daily quota as a second action.
41+
*/
42+
suspend fun generate(
43+
confirmed: ConfirmedPreview,
44+
options: AiGenerateOptions = AiGenerateOptions(),
45+
): AiResult<AiGeneration>
46+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.interlinedlist.android.feature.ai.data
2+
3+
import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
4+
import com.interlinedlist.android.core.common.result.ApiResult
5+
import com.interlinedlist.android.core.network.api.InterlinedListApi
6+
import com.interlinedlist.android.core.network.dto.toDomain
7+
import com.interlinedlist.android.core.network.error.safeApiCall
8+
import com.interlinedlist.android.feature.ai.data.mapper.toAvailability
9+
import com.interlinedlist.android.feature.ai.data.mapper.toDomain
10+
import com.interlinedlist.android.feature.ai.data.remote.AiApi
11+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateRequest
12+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestRequest
13+
import com.interlinedlist.android.feature.ai.domain.AiAvailability
14+
import com.interlinedlist.android.feature.ai.domain.AiFeature
15+
import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions
16+
import com.interlinedlist.android.feature.ai.domain.AiGeneration
17+
import com.interlinedlist.android.feature.ai.domain.AiPreview
18+
import com.interlinedlist.android.feature.ai.domain.AiResult
19+
import com.interlinedlist.android.feature.ai.domain.AiSuggestInput
20+
import com.interlinedlist.android.feature.ai.domain.ConfirmedPreview
21+
import com.interlinedlist.android.feature.ai.domain.map
22+
import kotlinx.coroutines.withContext
23+
import kotlinx.serialization.json.Json
24+
import javax.inject.Inject
25+
26+
class DefaultAiRepository @Inject constructor(
27+
private val api: AiApi,
28+
/** Shared current-user endpoint, used only for the `customerStatus` fallback. */
29+
private val userApi: InterlinedListApi,
30+
private val json: Json,
31+
private val dispatchers: DispatcherProvider,
32+
) : AiRepository {
33+
34+
override suspend fun availability(): AiAvailability = withContext(dispatchers.io) {
35+
when (val status = aiApiCall(json) { api.getStatus() }) {
36+
// Status unreadable (offline, 401, unexpected body) — hide AI rather
37+
// than offering a control whose action would fail.
38+
is AiResult.Failure -> AiAvailability.Unavailable
39+
is AiResult.Success ->
40+
status.data.toAvailability(status.data.subscriber ?: currentUserIsSubscriber())
41+
}
42+
}
43+
44+
override suspend fun suggest(
45+
feature: AiFeature,
46+
input: AiSuggestInput,
47+
): AiResult<AiPreview> = withContext(dispatchers.io) {
48+
val request = AiSuggestRequest(
49+
feature = feature.apiValue,
50+
input = input.input,
51+
context = input.context,
52+
model = input.model,
53+
maxOutputTokens = input.maxOutputTokens,
54+
)
55+
aiApiCall(json) { api.suggest(request) }.map { it.toDomain(feature) }
56+
}
57+
58+
override suspend fun generate(
59+
confirmed: ConfirmedPreview,
60+
options: AiGenerateOptions,
61+
): AiResult<AiGeneration> = withContext(dispatchers.io) {
62+
val request = AiGenerateRequest(
63+
feature = confirmed.feature.apiValue,
64+
// Sent back exactly as confirmed; the server re-validates it.
65+
artifact = confirmed.artifact.payload,
66+
model = options.model,
67+
scheduleImmediately = options.scheduleImmediately,
68+
crossPost = options.crossPost,
69+
)
70+
aiApiCall(json) { api.generate(request) }.map { it.toDomain(confirmed.feature) }
71+
}
72+
73+
/**
74+
* `/api/ai/status` reports `subscriber` itself; this covers a deployment that
75+
* omits it, reusing the same `customerStatus` the rest of the app gates on.
76+
* Null means the subscription could not be established at all.
77+
*/
78+
private suspend fun currentUserIsSubscriber(): Boolean? =
79+
when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) {
80+
is ApiResult.Success -> result.data.toDomain().customerStatus.isSubscriber
81+
is ApiResult.Failure -> null
82+
}
83+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.interlinedlist.android.feature.ai.data.mapper
2+
3+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiCreatedDto
4+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateResponse
5+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiQuotaDto
6+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiStatusDto
7+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestResponse
8+
import com.interlinedlist.android.feature.ai.data.remote.dto.AiUsageDto
9+
import com.interlinedlist.android.feature.ai.domain.AiArtifact
10+
import com.interlinedlist.android.feature.ai.domain.AiAvailability
11+
import com.interlinedlist.android.feature.ai.domain.AiCreated
12+
import com.interlinedlist.android.feature.ai.domain.AiFeature
13+
import com.interlinedlist.android.feature.ai.domain.AiGeneration
14+
import com.interlinedlist.android.feature.ai.domain.AiPreview
15+
import com.interlinedlist.android.feature.ai.domain.AiQuota
16+
import com.interlinedlist.android.feature.ai.domain.AiUsage
17+
import kotlinx.serialization.json.JsonObject
18+
19+
internal fun AiQuotaDto.toDomain(): AiQuota = AiQuota(
20+
usedToday = usedToday,
21+
dailyLimit = dailyLimit,
22+
remaining = remaining,
23+
)
24+
25+
internal fun AiUsageDto.toDomain(): AiUsage = AiUsage(
26+
inputTokens = inputTokens,
27+
outputTokens = outputTokens,
28+
model = model,
29+
)
30+
31+
/**
32+
* Resolves `/api/ai/status` into the gate state.
33+
*
34+
* [subscriber] is the caller's resolved subscription flag — the body's own
35+
* `subscriber` when it sent one, otherwise the account's `customerStatus`, and
36+
* null when neither could be read.
37+
*
38+
* Precedence: an explicitly empty `providers` array means the deployment has no
39+
* AI key at all and hides AI for everyone, subscriber or not. An *absent*
40+
* `providers` field is not the same claim, so it does not hide anything. A
41+
* subscription that cannot be confirmed hides AI too — a free account must
42+
* never see an AI control.
43+
*/
44+
internal fun AiStatusDto.toAvailability(subscriber: Boolean?): AiAvailability = when {
45+
providers?.isEmpty() == true -> AiAvailability.Unavailable
46+
subscriber == null -> AiAvailability.Unavailable
47+
!subscriber -> AiAvailability.NotSubscribed
48+
else -> AiAvailability.Available(quota?.toDomain())
49+
}
50+
51+
/** Builds the preview, defaulting the feature to the one that was requested. */
52+
internal fun AiSuggestResponse.toDomain(requested: AiFeature): AiPreview = AiPreview(
53+
feature = AiFeature.fromApiValue(feature) ?: requested,
54+
artifact = AiArtifact(artifact ?: JsonObject(emptyMap())),
55+
usage = usage?.toDomain(),
56+
quota = quota?.toDomain(),
57+
)
58+
59+
internal fun AiGenerateResponse.toDomain(requested: AiFeature): AiGeneration = AiGeneration(
60+
feature = AiFeature.fromApiValue(feature) ?: requested,
61+
created = created?.toDomain() ?: AiCreated.Unrecognised,
62+
quota = quota?.toDomain(),
63+
)
64+
65+
/**
66+
* Picks the one populated group. Scheduled posts and a document folder are
67+
* checked first because those responses also carry the ids of what they wrap.
68+
*/
69+
internal fun AiCreatedDto.toDomain(): AiCreated = when {
70+
!scheduledMessageIds.isNullOrEmpty() -> AiCreated.ScheduledMessagesCreated(
71+
messageIds = scheduledMessageIds,
72+
firstScheduledAt = firstScheduledAt,
73+
lastScheduledAt = lastScheduledAt,
74+
)
75+
folderId != null -> AiCreated.DocumentSeriesCreated(
76+
folderId = folderId,
77+
documentIds = documentIds.orEmpty(),
78+
)
79+
listId != null -> AiCreated.ListCreated(listId)
80+
documentId != null -> AiCreated.DocumentCreated(documentId)
81+
else -> AiCreated.Unrecognised
82+
}

0 commit comments

Comments
 (0)