Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
402e457
refactor(ai): rename AiOrchestrator to AiHandler
VoidX3D Jun 15, 2026
71dd8fb
feat(ai): add UnifiedModelFilter for consistent model filtering acros…
VoidX3D Jun 15, 2026
d599acb
refactor(ai): unify DeepSeek/Groq/Mistral to GenericOpenAiClient
VoidX3D Jun 15, 2026
5d08f1b
feat(ai): add CUSTOM provider entry to AiProvider enum
VoidX3D Jun 15, 2026
b1f3c80
feat(ai): add CUSTOM provider and createClientWithUrl to AiClientFactory
VoidX3D Jun 15, 2026
a5fdfef
feat(ai): add CUSTOM to provider fallback chain in AiProviderSupport
VoidX3D Jun 15, 2026
cf7f4b5
feat(ai): add base URL support and CUSTOM provider prefs to AiPrefere…
VoidX3D Jun 15, 2026
c8fd759
feat(ai): add OLLAMA provider entry to AiProvider enum
VoidX3D Jun 15, 2026
c83cc4f
feat(ai): add OLLAMA provider implementation to AiClientFactory
VoidX3D Jun 15, 2026
f13e7da
feat(ai): add OLLAMA to provider fallback chain
VoidX3D Jun 15, 2026
452dcfe
feat(ai): add OLLAMA provider convenience flows to AiPreferencesRepos…
VoidX3D Jun 15, 2026
3abd56b
feat(ai): add SearchableModelSelector composable with search bar
VoidX3D Jun 15, 2026
775766d
feat(ai): add Ollama/Custom provider flows and base URL state to Sett…
VoidX3D Jun 15, 2026
ee57fae
feat(ui): add OLLAMA/CUSTOM provider labels, SearchableModelSelector,…
VoidX3D Jun 15, 2026
ca27d41
chore: remove unused DeepSeekAiClient, GroqAiClient, MistralAiClient …
VoidX3D Jun 15, 2026
bd711b5
feat(ai): add topP, topK, maxTokens, presencePenalty, frequencyPenalt…
VoidX3D Jun 15, 2026
70c756b
feat(ai): add topP, maxTokens, presencePenalty, frequencyPenalty to G…
VoidX3D Jun 15, 2026
479104f
feat(ai): add topP, topK, maxTokens, presencePenalty, frequencyPenalt…
VoidX3D Jun 15, 2026
9ddb02b
feat(ai): add generation parameter and song data configuration prefer…
VoidX3D Jun 15, 2026
fa1ccad
feat(ai): overhaul AiSystemPromptEngine with chain-of-thought, few-sh…
VoidX3D Jun 15, 2026
19df14f
feat(ai): fetch and pass generation parameters from preferences in Ai…
VoidX3D Jun 15, 2026
2963295
feat(ai): make digest sample size, mode, and extended fields configur…
VoidX3D Jun 15, 2026
e7a17b6
feat(ai): add generation parameter flows and handlers to SettingsView…
VoidX3D Jun 15, 2026
aba3bbf
feat(ui): add Generation Parameters and Song Data Configuration secti…
VoidX3D Jun 15, 2026
51049de
fix: resolve compilation errors in AI settings UI
VoidX3D Jun 15, 2026
3f10397
fix(ai): add AiResponseCleaner, fix usage tracking model name, robust…
VoidX3D Jun 15, 2026
9ad2e8d
refactor: remove AiMetadataGenerator and fix compilation errors
VoidX3D Jun 15, 2026
e771316
refactor: improve translateLyrics prompt with XML structure
VoidX3D Jun 15, 2026
49d890e
fix: restore playlist generation state tracking
VoidX3D Jun 15, 2026
43d79ca
fix: update API key messages and enhance AI provider key handling
VoidX3D Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class AiOrchestrator @Inject constructor(
class AiHandler @Inject constructor(
private val preferencesRepo: AiPreferencesRepository,
private val clientFactory: AiClientFactory,
private val cacheDao: AiCacheDao,
Expand Down Expand Up @@ -60,58 +60,79 @@ class AiOrchestrator @Inject constructor(
preferencesRepo.setModel(provider, model)
}

private data class GenerationParams(
val temperature: Float,
val topP: Float,
val topK: Int,
val maxTokens: Int,
val presencePenalty: Float,
val frequencyPenalty: Float,
)

private data class GenerationResult(
val response: String,
val modelUsed: String,
)

private suspend fun getGenerationParams(): GenerationParams {
return GenerationParams(
temperature = preferencesRepo.aiTemperature.first(),
topP = preferencesRepo.aiTopP.first(),
topK = preferencesRepo.aiTopK.first(),
maxTokens = preferencesRepo.aiMaxTokens.first(),
presencePenalty = preferencesRepo.aiPresencePenalty.first(),
frequencyPenalty = preferencesRepo.aiFrequencyPenalty.first(),
)
}

private suspend fun generateWithRecovery(
provider: AiProvider,
apiKey: String,
systemPrompt: String,
prompt: String,
temperature: Float
): String {
temperature: Float,
topP: Float,
topK: Int,
maxTokens: Int,
presencePenalty: Float,
frequencyPenalty: Float,
): GenerationResult {
val client = clientFactory.createClient(provider, apiKey)
val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() }

return try {
// Wrap in timeout to prevent hanging requests
withTimeout(REQUEST_TIMEOUT_MS) {
client.generateContent(
requestedModel,
systemPrompt,
prompt,
temperature
suspend fun callWithModel(model: String): String {
return try {
withTimeout(REQUEST_TIMEOUT_MS) {
client.generateContent(
model, systemPrompt, prompt, temperature,
topP, topK, maxTokens, presencePenalty, frequencyPenalty,
)
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException(
providerName = provider.displayName,
statusCode = null,
transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.",
responseBody = null,
requestedModel = model
)
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException(
providerName = provider.displayName,
statusCode = null,
transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.",
responseBody = null,
requestedModel = requestedModel
)
}

return try {
val response = callWithModel(requestedModel)
GenerationResult(response, requestedModel)
} catch (e: Exception) {
val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(
provider.displayName,
e,
requestedModel
provider.displayName, e, requestedModel
)

val recoveredModel = recoverModelIfNeeded(
provider = provider,
apiKey = apiKey,
requestedModel = requestedModel,
client = client,
failure = failure
provider, apiKey, requestedModel, client, failure
) ?: throw failure

// Retry with recovered model (also with timeout)
withTimeout(REQUEST_TIMEOUT_MS) {
client.generateContent(
recoveredModel,
systemPrompt,
prompt,
temperature
)
}
val response = callWithModel(recoveredModel)
GenerationResult(response, recoveredModel)
}
}

Expand Down Expand Up @@ -141,48 +162,40 @@ class AiOrchestrator @Inject constructor(
temperature: Float = 0.7f,
context: String = ""
): String {
// Dynamic temperature adjustment if default value is used
val resolvedTemperature = if (temperature == 0.7f) {
when (type) {
// AI Optimization: Use low temperature for high-precision metadata to prevent hallucinations
AiSystemPromptType.METADATA -> 0.1f
AiSystemPromptType.MOOD_ANALYSIS -> 0.2f
// AI Optimization: Moderate temperature for tags to allow creative yet relevant descriptors
AiSystemPromptType.TAGGING -> 0.4f
// AI Optimization: Balanced temperature for playlists to ensure variety without losing cohesion
AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f
// AI Optimization: High temperature for persona-based responses to increase flair and engagement
AiSystemPromptType.PERSONA -> 0.85f
AiSystemPromptType.GENERAL -> 0.7f
}
} else temperature
val params = getGenerationParams()
val effectiveTemperature = if (params.temperature == 0.7f) {
if (temperature == 0.7f) {
when (type) {
AiSystemPromptType.METADATA -> 0.1f
AiSystemPromptType.MOOD_ANALYSIS -> 0.2f
AiSystemPromptType.TAGGING -> 0.4f
AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f
AiSystemPromptType.PERSONA -> 0.85f
AiSystemPromptType.GENERAL -> 0.7f
}
} else temperature
} else params.temperature

// Determine chain based on user preference
val userProviderStr = preferencesRepo.aiProvider.first()
val userProvider = AiProvider.fromString(userProviderStr)

// Generate combined prompt for hashing and execution
val basePersona = getBasePersona(userProvider)
val combinedSystemPrompt = promptEngine.buildPrompt(basePersona, type, context)

// Cache entry is valid for a specific prompt + system instruction + provider

val hash = (userProvider.name + combinedSystemPrompt + prompt).sha256()

// Check cache with TTL — don't serve stale results
cacheDao.getCache(hash)?.let { cached ->
val age = System.currentTimeMillis() - cached.timestamp
if (age < CACHE_TTL_MS) {
return cached.responseJson
}
// Cache expired — proceed with fresh generation
}

val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider)
val failedProviders = mutableListOf<String>()
val now = System.currentTimeMillis()

for (provider in providersToTry) {
// Skip if in cooldown
val cooldownExpiry = providerCooldowns[provider] ?: 0L
if (now < cooldownExpiry) {
failedProviders.add("${provider.name}: on cooldown (${((cooldownExpiry - now) / 1000)}s remaining)")
Expand All @@ -196,29 +209,30 @@ class AiOrchestrator @Inject constructor(
continue
}

// Use the shared base persona but specialized type rules for each provider in the chain
val providerPersona = getBasePersona(provider)
val finalSystemPrompt = promptEngine.buildPrompt(providerPersona, type, context)

val response = generateWithRecovery(
val result = generateWithRecovery(
provider = provider,
apiKey = apiKey,
systemPrompt = finalSystemPrompt,
prompt = prompt,
temperature = resolvedTemperature
temperature = effectiveTemperature,
topP = params.topP,
topK = params.topK,
maxTokens = params.maxTokens,
presencePenalty = params.presencePenalty,
frequencyPenalty = params.frequencyPenalty,
)

// Validate response is not empty
if (response.isBlank()) {
if (result.response.isBlank()) {
failedProviders.add("${provider.name}: returned empty response")
continue
}

// Low-maintenance usage tracking using highly accurate proportional estimation bounds (4 chars ~ 1 token)
// Models with "thinking" or "reasoning" generally output 2-3x internal tokens for complex generation
val isThinkingModel = finalSystemPrompt.contains("think", true) || provider.name.contains("reasoning", true)
val estimatedPromptTokens = (finalSystemPrompt.length + prompt.length) / 4
val estimatedOutputTokens = response.length / 4
val estimatedOutputTokens = result.response.length / 4
val estimatedThoughtTokens = if (isThinkingModel) (estimatedOutputTokens * 1.5).toInt() else 0

appScope.launch {
Expand All @@ -227,24 +241,24 @@ class AiOrchestrator @Inject constructor(
AiUsageEntity(
timestamp = now,
provider = provider.displayName,
model = provider.name,
model = result.modelUsed,
promptType = type.name,
promptTokens = estimatedPromptTokens,
outputTokens = estimatedOutputTokens,
thoughtTokens = estimatedThoughtTokens
)
)
}.onFailure { error ->
Timber.tag("AiOrchestrator").e(error, "Failed to persist AI usage")
Timber.tag("AiHandler").e(error, "Failed to persist AI usage")
}
}

cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = response, timestamp = System.currentTimeMillis()))
return response
cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = result.response, timestamp = System.currentTimeMillis()))
return result.response
} catch (e: Exception) {
// AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain
val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e)
Timber.tag("AiOrchestrator").w(e, "Provider ${provider.name} failed: ${failure.message}")
Timber.tag("AiHandler").w(e, "Provider ${provider.name} failed: ${failure.message}")
failedProviders.add("${provider.name}: ${failure.message ?: "Unknown error"}")
// Trigger cooldown only on provider-level outages and account problems.
if (failure.shouldCooldown()) {
Expand All @@ -268,7 +282,7 @@ class AiOrchestrator @Inject constructor(
"AI generation failed after trying ${failedProviders.size} providers:\n${failedProviders.joinToString("\n• ", prefix = "• ")}"
}

Timber.tag("AiOrchestrator").e("All providers failed. Details: %s", failedProviders.joinToString(" | "))
Timber.tag("AiHandler").e("All providers failed. Details: %s", failedProviders.joinToString(" | "))
throw Exception(errorMessage)
}
}

This file was deleted.

Loading
Loading