diff --git a/.gitignore b/.gitignore index 2d849c11e..b0708d96b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ third_party/sherpa-onnx/ # Legacy local Sherpa voice assets (kept ignored if they still exist from old setup flows). core/voice/src/main/assets/sherpa-tts/ + +# Agent plans (local-only) +.omp/plans/ diff --git a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt index 46eedc986..7b54472d4 100644 --- a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt +++ b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt @@ -966,7 +966,10 @@ class LiteRtInferenceEngine @Inject constructor( systemPrompt: String?, thinkingEnabled: Boolean?, ): String = withContext(LlmDispatcher) { - val config = currentConfig ?: return@withContext "" + val config = currentConfig ?: run { + Log.w(TAG, "generateStructuredOnce: currentConfig is null — engine not initialized?") + return@withContext "" + } Log.d( TAG, "generateStructuredOnce: spec='${spec.toolName}', schemaLen=${spec.jsonSchema.length}, thinking=$thinkingEnabled", @@ -1024,7 +1027,10 @@ class LiteRtInferenceEngine @Inject constructor( safeClose(conversation, "conversation") } - val eng = engine ?: return@withContext "" + val eng = engine ?: run { + Log.w(TAG, "generateStructuredOnce: engine is null — was it evicted?") + return@withContext "" + } val convConfig = buildConversationConfig(_activeBackend.value ?: BackendType.CPU, requestedConfig) // Isolate to synthetic tool only — no other tools interfere with constrained decoding. @@ -1184,13 +1190,15 @@ class LiteRtInferenceEngine @Inject constructor( } } finally { resetExperimentalFlags() - if (shouldSwapConfig) { - resetConversationForConfig(config) - } } } finally { generationMutex.unlock() + if (shouldSwapConfig) { + resetConversationForConfig(config) + } } + } catch (ce: CancellationException) { + throw ce } catch (e: Exception) { Log.w(TAG, "generateStructuredOnce: error", e) "" diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index b8068fdd1..8ec4d5951 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -3,7 +3,6 @@ package com.kernel.ai.core.skills.mealplan import android.util.Log import com.kernel.ai.core.inference.EmbeddingEngine import com.kernel.ai.core.inference.InferenceEngine -import com.kernel.ai.core.inference.StructuredOutputSpec import com.kernel.ai.core.memory.mealplan.FavouriteRecipeMode import com.kernel.ai.core.memory.mealplan.FavouriteRecipeSummary import com.kernel.ai.core.memory.mealplan.MealPlanDayStatus @@ -18,6 +17,7 @@ import com.kernel.ai.core.memory.repository.MealPlanSessionRepository import com.kernel.ai.core.memory.repository.MemoryRepository import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.time.LocalDateTime @@ -396,41 +396,66 @@ class MealPlannerCoordinator @Inject constructor( onPlannerActivityChanged(generatingPlanActivity(snapshot)) val recentHistory = sessionRepository.getRecentMealHistory(RECENT_MEAL_HISTORY_LIMIT) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) - val rawPlan = inferenceEngine.generateStructuredOnce( - prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.MealPlan, - systemPrompt = buildPlanSystemPrompt(), - thinkingEnabled = false, - ) - if (rawPlan.isBlank()) { - sessionRepository.markGenerationFailure( - snapshot.sessionId, - null, - "PLAN_NO_OUTPUT", - "The model did not return a plan.", - ) - return@withSessionGeneration MealPlannerReply( - "I couldn't finish building the plan because the model didn't return one. Try replying with the same requirements again.", - ) - } - val parsedPlan = try { - jsonParser.parsePlanDraft(rawPlan, snapshot.daysCount ?: 0) - } catch (e: MealPlanValidationException) { - sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_JSON_INVALID", e.message ?: "Invalid plan JSON") - return@withSessionGeneration MealPlannerReply("I couldn't generate a valid high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.") - } - val planDraft = try { - repairPlanVariety( - snapshot = snapshot, - draft = parsedPlan, - recentHistory = recentHistory, - ) - } catch (e: MealPlanValidationException) { - sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_VARIETY_REPAIR_FAILED", e.message ?: "Plan was too repetitive") - return@withSessionGeneration MealPlannerReply("I couldn't generate a varied enough high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.") + var lastErrorCode: String? = null + var lastErrorMessage: String? = null + for (attempt in 1..MAX_PLAN_GENERATION_ATTEMPTS) { + Log.d(TAG, "Plan generation attempt $attempt/$MAX_PLAN_GENERATION_ATTEMPTS: sessionId=${snapshot.sessionId}") + val preAttempt = sessionRepository.getSession(snapshot.sessionId) ?: snapshot + if (preAttempt.status == MealPlanSessionStatus.CANCELLED) { + return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") + } + val rawPlan = try { + inferenceEngine.generateOnce( + prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), + systemPrompt = buildPlanSystemPrompt(), + thinkingEnabled = false, + stopOnFirstJsonObject = true, + ) + } catch (ce: CancellationException) { + Log.w(TAG, "Plan generation cancelled: sessionId=${snapshot.sessionId}, attempt=$attempt") + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_CANCELLED", "Generation job was cancelled") + return@withSessionGeneration MealPlannerReply("Meal planning was interrupted. Say 'generate recipes' to try again.") + } + if (rawPlan.isBlank()) { + lastErrorCode = "PLAN_NO_OUTPUT" + lastErrorMessage = "The model did not return a plan." + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode, responseWasBlank=true") + if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue + sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) + } + val parsedPlan = try { + jsonParser.parsePlanDraft(rawPlan, snapshot.daysCount ?: 0) + } catch (e: MealPlanValidationException) { + lastErrorCode = "PLAN_JSON_INVALID" + lastErrorMessage = e.message ?: "Invalid plan JSON" + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode, responsePreview=${rawPlan.take(200)}") + if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue + sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) + } + val planDraft = try { + repairPlanVariety( + snapshot = snapshot, + draft = parsedPlan, + recentHistory = recentHistory, + ) + } catch (e: MealPlanValidationException) { + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_VARIETY_REPAIR_FAILED", e.message ?: "Plan was too repetitive") + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=PLAN_VARIETY_REPAIR_FAILED, conflictCount=${e.message?.take(100)}") + return@withSessionGeneration MealPlannerReply( + "I couldn't generate a varied enough high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.", + ) + } + val preSave = sessionRepository.getSession(snapshot.sessionId) ?: snapshot + if (preSave.status == MealPlanSessionStatus.CANCELLED) { + return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") + } + val planned = sessionRepository.savePlanDraft(snapshot.sessionId, planDraft.days) + return@withSessionGeneration MealPlannerReply(planReviewPrompt(planned)) } - val planned = sessionRepository.savePlanDraft(snapshot.sessionId, planDraft.days) - MealPlannerReply(planReviewPrompt(planned)) + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_GENERATION_FAILED", "Exhausted all attempts") + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) } private suspend fun repairPlanVariety( @@ -490,12 +515,13 @@ class MealPlannerCoordinator @Inject constructor( ): MealPlanDraftDay { val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) + Log.d(TAG, "Variety repair started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, attemptLimit=$MAX_DAY_VARIETY_REPAIR_ATTEMPTS") repeat(MAX_DAY_VARIETY_REPAIR_ATTEMPTS) { - val raw = inferenceEngine.generateStructuredOnce( + val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, currentDays, dayIndex, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.ReplacementDay, systemPrompt = buildReplacementDaySystemPrompt(dayIndex), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (raw.isBlank()) { return@repeat @@ -933,11 +959,12 @@ class MealPlannerCoordinator @Inject constructor( if (markPendingGeneration) { sessionRepository.markPendingGeneration(snapshot.sessionId, PendingGenerationKind.RECIPE, dayIndex) } - val rawRecipe = inferenceEngine.generateStructuredOnce( + Log.d(TAG, "Recipe generation started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, dayTitle=${day.title ?: "unnamed"}") + val rawRecipe = inferenceEngine.generateOnce( prompt = buildRecipeUserPrompt(snapshot, dayIndex), - spec = StructuredOutputSpec.Recipe, systemPrompt = buildRecipeSystemPrompt(), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (rawRecipe.isBlank()) { sessionRepository.markGenerationFailure( @@ -967,6 +994,7 @@ class MealPlannerCoordinator @Inject constructor( rawModelJson = rawRecipe, groceries = groceries, ) + Log.d(TAG, "Recipe generation success: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, title=${recipe.title}") return GeneratedRecipeResult(updated, recipe, day.title ?: recipe.title) } private suspend fun replaceDaysAndGenerateRecipes( @@ -1217,11 +1245,12 @@ class MealPlannerCoordinator @Inject constructor( val recentHistory = sessionRepository.getRecentMealHistory(RECENT_MEAL_HISTORY_LIMIT) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) - val raw = inferenceEngine.generateStructuredOnce( + Log.d(TAG, "Replacement day generation started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex") + val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, dayIndex, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.ReplacementDay, systemPrompt = buildReplacementDaySystemPrompt(dayIndex), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (raw.isBlank()) { throw MealPlanValidationException("The model didn't return a replacement day.") @@ -1246,7 +1275,7 @@ class MealPlannerCoordinator @Inject constructor( ) { throw MealPlanValidationException("Replacement day still duplicated another planned meal too closely.") } - return sessionRepository.replaceDayDraft( + val updated = sessionRepository.replaceDayDraft( sessionId = snapshot.sessionId, dayIndex = dayIndex, title = replacement.title, @@ -1254,6 +1283,8 @@ class MealPlannerCoordinator @Inject constructor( proteinTags = replacement.proteinTags, recipeGenerationPending = !markPendingGeneration, ) + Log.d(TAG, "Replacement day generated: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, title=${replacement.title}") + return updated } catch (e: MealPlanValidationException) { sessionRepository.clearPendingGeneration(snapshot.sessionId) throw e @@ -1316,7 +1347,14 @@ class MealPlannerCoordinator @Inject constructor( generationActive: Boolean = false, ): MealPlannerActivity? = when (snapshot.status) { MealPlanSessionStatus.COLLECTING_REQUIRED_SLOTS -> collectingActivity(snapshot) - MealPlanSessionStatus.PLAN_REVIEW -> if (generationActive && snapshot.pendingGenerationKind == PendingGenerationKind.PLAN) { + MealPlanSessionStatus.PLAN_REVIEW -> if (snapshot.days.isEmpty()) { + MealPlannerActivity( + title = "Meal Plan", + subtitle = "The plan couldn't be built. Say 'generate recipes' to try again, or 'change preferences'.", + state = MealPlannerActivityState.WAITING, + suggestions = planReviewSuggestions(snapshot), + ) + } else if (generationActive && snapshot.pendingGenerationKind == PendingGenerationKind.PLAN) { generatingPlanActivity(snapshot) } else { MealPlannerActivity( @@ -1469,7 +1507,7 @@ class MealPlannerCoordinator @Inject constructor( MealPlanSessionStatus.COLLECTING_REQUIRED_SLOTS -> collectingHelpPrompt(snapshot) MealPlanSessionStatus.PLAN_REVIEW -> if (snapshot.days.isEmpty()) { - "I still need to rebuild your meal plan draft. You can say 'generate recipes' to try again, 'change preferences' to edit the plan details, 'show current plan' to inspect what I have, or 'cancel' to stop." + "I couldn't build the meal plan yet. You can say 'generate recipes' to try again, 'change preferences' to edit the plan details, or 'cancel' to stop." } else { "You're reviewing the draft meal plan. You can say 'show current plan' to inspect it again, 'generate recipes' to build the recipe details, 'replace day 1' to swap one meal, 'change preferences' to edit people, days, dietary needs, proteins, or cuisines, or 'cancel' to stop." } @@ -1576,6 +1614,20 @@ class MealPlannerCoordinator @Inject constructor( if (snapshot.cuisinePreferences.isEmpty()) add("cuisine") } + private fun planGenerationFailedMessage(snapshot: MealPlanSnapshot): String { + if (snapshot.days.isNotEmpty()) { + return "I couldn't rebuild the meal plan with your updated preferences. Your previous draft is still shown — say 'generate recipes' to try again with the updated preferences, 'change preferences' to adjust your requirements, or 'help' for more options." + } + return "I couldn't build the meal plan. Say 'generate recipes' to try again, 'change preferences' to adjust your requirements, or 'help' for more options." + } + + private fun planReviewPrompt(snapshot: MealPlanSnapshot): String = + if (snapshot.days.isEmpty()) { + planGenerationFailedMessage(snapshot) + } else { + buildPlanSummary(snapshot) + "\n\n" + planReviewActionsPrompt() + } + private fun missingSlotPrompt(slot: String): String = when (slot) { "people" -> "- How many people are you cooking for?" "days" -> "- How many days do you want to plan for?" @@ -1585,12 +1637,6 @@ class MealPlannerCoordinator @Inject constructor( else -> "- $slot" } - private fun planReviewPrompt(snapshot: MealPlanSnapshot): String = - if (snapshot.days.isEmpty()) { - "I still need to rebuild your meal plan draft. Say 'generate recipes' to try again, 'change preferences', 'help' for more options, or 'cancel'." - } else { - buildPlanSummary(snapshot) + "\n\n" + planReviewActionsPrompt() - } private fun currentPlanReply(snapshot: MealPlanSnapshot): String = if (snapshot.days.isEmpty()) { @@ -1722,8 +1768,8 @@ class MealPlannerCoordinator @Inject constructor( private fun buildPlanSystemPrompt(): String = """ You generate a high-level meal plan for a local-first Android assistant. -You MUST call the tool `emit_meal_plan` with your plan as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "days": [ { @@ -1774,8 +1820,8 @@ Rules: private fun buildRecipeSystemPrompt(): String = """ You generate one recipe day for a local-first Android assistant. -You MUST call the tool `emit_recipe` with your recipe as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "title": "...", "servings": 4, @@ -1818,8 +1864,8 @@ Provide a practical Australia/New Zealand dinner recipe with a concise ingredien private fun buildReplacementDaySystemPrompt(dayIndex: Int): String = """ You generate a replacement high-level meal-plan day for a local-first Android assistant. -You MUST call the tool `emit_replacement_day` with your replacement day as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "days": [ { @@ -2039,6 +2085,13 @@ Rules: else -> slot } private fun planReviewSuggestions(snapshot: MealPlanSnapshot): List = buildList { + if (snapshot.days.isEmpty()) { + add(suggestion("Generate recipes", "generate recipes")) + add(suggestion("Change preferences", "change preferences")) + add(suggestion("Help", "help")) + add(suggestion("Cancel plan", "cancel plan")) + return@buildList + } add(suggestion("Generate recipes", "generate recipes")) add(suggestion("Show current plan", "show current plan")) primaryEditableDay(snapshot)?.let { dayIndex -> @@ -2062,40 +2115,40 @@ Rules: } private fun MutableList.addPeopleSuggestions(isTop: Boolean) { - add(suggestion("2 people", "2 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("2 people", "2 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("3 people", "3 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("6 people", "6 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("8 people", "8 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("3 people", "3 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("6 people", "6 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("8 people", "8 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } else { - add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } } private fun MutableList.addDaysSuggestions(isTop: Boolean) { - add(suggestion("4 days", "4 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("4 days", "4 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("3 days", "3 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("5 days", "5 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("14 days", "14 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("3 days", "3 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("5 days", "5 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("14 days", "14 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } else { - add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } } private fun MutableList.addDietarySuggestions(isTop: Boolean) { - add(suggestion("no dietary requirements", "no dietary requirements", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("no dietary requirements", "no dietary requirements", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("nut free", "nut free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("vegetarian", "vegetarian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("vegan", "vegan", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("nut free", "nut free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("vegetarian", "vegetarian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("vegan", "vegan", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } else { - add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } @@ -2113,34 +2166,33 @@ Rules: "tofu", "eggs", "chickpeas", - "no protein preference", ) val compatible = allOptions.filter { protein -> - protein == "no protein preference" || - detectProteinPreferenceConflicts(dietaryRestrictions, listOf(protein)).isEmpty() + detectProteinPreferenceConflicts(dietaryRestrictions, listOf(protein)).isEmpty() } + add(suggestion("no protein preference", "no protein preference", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - compatible.take(6).forEach { protein -> - add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + compatible.take(5).forEach { protein -> + add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } else { - compatible.take(3).forEach { protein -> - add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + compatible.take(2).forEach { protein -> + add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } } private fun MutableList.addCuisineSuggestions(isTop: Boolean) { - add(suggestion("no cuisine preference", "no cuisine preference", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("no cuisine preference", "no cuisine preference", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("indian", "indian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("thai", "thai", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("japanese", "japanese", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("indian", "indian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("thai", "thai", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("japanese", "japanese", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } else { - add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } @@ -2265,6 +2317,7 @@ Rules: private const val PENDING_COMPLETED_SUMMARY_LIMIT = 3 private const val MAX_PLAN_VARIETY_REPAIR_PASSES = 2 private const val MAX_DAY_VARIETY_REPAIR_ATTEMPTS = 2 + private const val MAX_PLAN_GENERATION_ATTEMPTS = 5 private const val MAX_PROMPT_HISTORY_TITLES = 6 private const val MAX_PROMPT_HISTORY_PATTERNS = 4 private val COMMON_PROTEINS = listOf( @@ -2307,6 +2360,7 @@ data class MealPlannerSuggestion( enum class MealPlannerSuggestionComposeMode { REPLACE, APPEND_COMMA, + STRIP_NEGATION_IF_APPENDING, } data class MealPlannerActivity( diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index 25dfa7a5a..e100d7aaa 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -135,7 +136,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") @@ -173,7 +174,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson(), "{}", replacementJson(dayIndex = 0, title = "Chicken schnitzel", proteinTag = "chicken"), @@ -187,16 +188,16 @@ class MealPlannerCoordinatorTest { assertEquals(listOf("Chicken schnitzel", "Beef bowls"), savedDays.map { it.title }) assertTrue(reply.content.contains("Chicken schnitzel", ignoreCase = true)) - coVerify(exactly = 3) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 3) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify { - inferenceEngine.generateStructuredOnce( + inferenceEngine.generateOnce( match { it.contains("Recent meals to avoid repeating", ignoreCase = true) && it.contains("Lemon chicken", ignoreCase = true) }, any(), any(), - false, + any(), ) } } @@ -227,7 +228,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Garlic chicken stir fry", day0Summary = "Fast skillet dinner", @@ -271,7 +272,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson( day0Title = "Garlic chicken stir fry", day0Summary = "Fast skillet dinner", day0Protein = "chicken", @@ -288,7 +289,7 @@ class MealPlannerCoordinatorTest { assertEquals(listOf("Garlic chicken stir fry", "Lemon chicken tray bake"), savedDays.take(2).map { it.title }) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -326,7 +327,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("beef"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Garlic chicken skillet", day0Summary = "Fast stovetop dinner", @@ -368,7 +369,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken", "beef"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Chicken stir fry", day0Summary = "Quick dinner", @@ -419,7 +420,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No dietary requirements") @@ -458,7 +459,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No restrictions") @@ -497,7 +498,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No requirements") @@ -536,7 +537,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("no protein preference"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Any") @@ -574,7 +575,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("don't fit vegetarian", ignoreCase = true)) assertTrue(reply.content.contains("no protein preference", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -606,7 +607,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("egg free", ignoreCase = true)) assertTrue(reply.content.contains("What protein preferences should I use?", ignoreCase = true)) assertFalse(reply.content.contains("don't fit egg free", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -638,7 +639,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("gluten free", ignoreCase = true)) assertTrue(reply.content.contains("egg free", ignoreCase = true)) assertFalse(reply.content.contains("no gluten free", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -659,7 +660,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns afterDay1 coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns completed @@ -685,7 +686,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns recipeJson("Lemon chicken") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns recipeJson("Lemon chicken") coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns cancelled val reply = coordinator.ingestUserMessage("conv", "generate recipes", onPlannerMessage = { emitted += it }) @@ -712,7 +713,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns afterDay1 coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns completed @@ -745,7 +746,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Day 1: Lemon chicken", ignoreCase = true)) assertTrue(reply.content.contains("Day 2: Turkey bowls", ignoreCase = true)) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `plan review change preferences returns to editable slot collection`() = runTest { @@ -796,7 +797,7 @@ class MealPlannerCoordinatorTest { favouriteRecipeMode = FavouriteRecipeMode.PREFER, ) } - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -834,7 +835,7 @@ class MealPlannerCoordinatorTest { ) val activities = mutableListOf() coEvery { sessionRepository.getActiveSession("conv") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns replacementJson(dayIndex = 0, title = "Turkey skillet") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns replacementJson(dayIndex = 0, title = "Turkey skillet") coEvery { sessionRepository.replaceDayDraft("session-1", 0, "Turkey skillet", any(), any(), false) } returns replaced coordinator.ingestUserMessage( @@ -885,7 +886,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("current plan details", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.markPendingGeneration("session-1", PendingGenerationKind.PLAN) } - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `collecting state can remove a single dietary restriction while preserving others`() = runTest { @@ -908,7 +909,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Remove gluten free") @@ -939,7 +940,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Remove no gluten free") @@ -971,7 +972,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "Remove no chicken") @@ -1009,7 +1010,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "Remove no chicken") @@ -1049,7 +1050,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("dietary requirements", ignoreCase = true)) assertFalse(reply.content.contains("no dietary requirements", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1072,7 +1073,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Current plan details", ignoreCase = true)) assertFalse(reply.content.contains("saved favourites preferred", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1096,7 +1097,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("salmon"), ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "remove beef and add salmon") @@ -1159,14 +1160,14 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns collecting - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "generate") assertTrue(reply.content.contains("Day 1: Lemon chicken", ignoreCase = true)) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `interrupted recipe generation requires explicit resume`() = runTest { @@ -1223,7 +1224,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns interrupted - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns replacementJson(dayIndex = 1, title = "Pan-Seared Chicken") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns replacementJson(dayIndex = 1, title = "Pan-Seared Chicken") coEvery { sessionRepository.replaceDayDraft("session-1", 0, "Pan-Seared Chicken", any(), any(), false) } returns replaced val reply = coordinator.ingestUserMessage("conv", "retry") @@ -1257,7 +1258,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns interrupted - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( replacementJson(dayIndex = 1, title = "Pan-Seared Chicken"), recipeJson("Pan-Seared Chicken"), ) @@ -1318,7 +1319,7 @@ class MealPlannerCoordinatorTest { "2 people", "3 people", "4 people", "6 people", "8 people", "4 days", "7 days", "no dietary requirements", "kid friendly", "gluten free", - "chicken", "beef mince", "beef", + "no protein preference", "chicken", "beef mince", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, @@ -1349,7 +1350,7 @@ class MealPlannerCoordinatorTest { assertEquals( listOf( - "chicken", "beef mince", "beef", "lamb", "pork", "fish", + "no protein preference", "chicken", "beef mince", "beef", "lamb", "pork", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, @@ -1387,7 +1388,7 @@ class MealPlannerCoordinatorTest { assertEquals( listOf( - "tofu", "eggs", "chickpeas", "no protein preference", + "no protein preference", "tofu", "eggs", "chickpeas", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, @@ -1421,7 +1422,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Day 1: Chicken stir-fry", ignoreCase = true)) assertTrue(reply.content.contains("Day 2: Chicken curry", ignoreCase = true)) assertTrue(reply.content.contains("done meal planning", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `interrupted regenerate day prompts for retry`() = runTest { @@ -1455,7 +1456,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns active - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( replacementJson(dayIndex = 2, title = "Turkey skillet"), recipeJson("Turkey skillet"), ) @@ -1482,7 +1483,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("can only replace", ignoreCase = true)) assertTrue(reply.content.contains("6", ignoreCase = true)) assertFalse(reply.content.contains("Invalid day index", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1495,7 +1496,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns active - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns recipeJson("Chicken curry") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns recipeJson("Chicken curry") coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns persisted val reply = coordinator.ingestUserMessage("conv", "regenerate day 2") @@ -1529,7 +1530,7 @@ class MealPlannerCoordinatorTest { ) } returns ready coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } coAnswers { + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } coAnswers { started.complete(Unit) release.await() "" @@ -1566,16 +1567,16 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns failed coEvery { sessionRepository.markGenerationFailure("session-1", 0, "RECIPE_NO_OUTPUT", "The model did not return a recipe.") } returns failed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val reply = coordinator.ingestUserMessage("conv", "regenerate day 1") assertTrue(reply.content.contains("didn't return a recipe", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test - fun `plan generation failure with empty draft stays recoverable and cannot finalize`() = runTest { + fun `plan generation failure with empty draft shows recovery message and cannot finalize`() = runTest { val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) val ready = collecting.copy( dietaryRestrictions = listOf("low lactose"), @@ -1596,17 +1597,19 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready + // After all retry attempts exhausted, markGenerationFailure is called coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val first = coordinator.ingestUserMessage("conv", "low lactose, chicken") - val second = coordinator.ingestUserMessage("conv", "hello") - val third = coordinator.ingestUserMessage("conv", "done meal planning") + val second = coordinator.ingestUserMessage("conv", "help") + val third = coordinator.ingestUserMessage("conv", "show current plan") - assertTrue(first.content.contains("didn't return one", ignoreCase = true)) - assertTrue(second.content.contains("rebuild your meal plan draft", ignoreCase = true)) - assertTrue(third.content.contains("rebuild your meal plan draft", ignoreCase = true)) + assertTrue(first.content.contains("couldn't build the meal plan", ignoreCase = true)) + assertTrue(second.content.contains("couldn't build the meal plan yet", ignoreCase = true)) + assertTrue(third.content.contains("couldn't build the meal plan", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.completeSession(any()) } + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1644,7 +1647,258 @@ class MealPlannerCoordinatorTest { coVerify { sessionRepository.completeSession("session-1") } coVerify { sessionRepository.buildFinalSummary("session-1") } coVerify { sessionRepository.markFinalSummaryWritten("session-1") } - coVerify(exactly = 1) { memoryRepository.addEpisodicMemory("conv", "Created a 2-day meal plan.", any()) } + } + + @Test + fun `blank plan output retries and succeeds on second attempt`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val reviewed = planReviewSnapshot() + coEvery { sessionRepository.getActiveSession("conv") } returns collecting + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf("", planJson()) + coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("Chicken", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateOnce(any(), any(), any(), any()) } + coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + + @Test + fun `invalid plan JSON retries and succeeds on second attempt`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val reviewed = planReviewSnapshot() + coEvery { sessionRepository.getActiveSession("conv") } returns collecting + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf("not valid json at all", planJson()) + coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("Chicken", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateOnce(any(), any(), any(), any()) } + coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + + @Test + fun `all plan generation attempts exhausted with blank output shows terminal recovery`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + } + + @Test + fun `all plan generation attempts exhausted with invalid JSON shows terminal recovery`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } returns failedPlan + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "invalid json" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + } + + @Test + fun `variety repair failure does not trigger outer retry`() = runTest { + val emptyPlanReview = planReviewSnapshot().copy(days = emptyList()) + coEvery { sessionRepository.getActiveSession("conv") } returns emptyPlanReview + coEvery { sessionRepository.getRecentMealHistory(any()) } returns listOf( + RecentMealHistoryEntry(title = "Lemon chicken", summary = "Quick dinner", proteinTags = listOf("chicken")), + RecentMealHistoryEntry(title = "Beef bowls", summary = "Weeknight bowl", proteinTags = listOf("beef")), + ) + // Plan JSON parses to 2 days, but both match recent history exactly. + // Replacement attempts return planJson() (2 days, not 1), so parseSinglePlanDay rejects them. + // This exhausts replacement retries and throws PLAN_VARIETY_REPAIR_FAILED. + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } returns emptyPlanReview + + val reply = coordinator.ingestUserMessage("conv", "generate recipes") + + assertTrue(reply.content.contains("varied enough"), "Expected 'varied enough' but got: ${reply.content}") + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } + } + + @Test + fun `cancellation during retry loop returns cancellation message`() = runTest { + val emptyPlanReview = planReviewSnapshot().copy(days = emptyList()) + coEvery { sessionRepository.getActiveSession("conv") } returns emptyPlanReview + coEvery { sessionRepository.getSession("session-1") } returns emptyPlanReview.copy( + status = MealPlanSessionStatus.CANCELLED, + ) + + val reply = coordinator.ingestUserMessage("conv", "generate recipes") + + assertTrue(reply.content.contains("cancelled", ignoreCase = true)) + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + + @Test + fun `empty plan activity shows recovery subtitle and suggestions`() = runTest { + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returns failedPlan + val activity = coordinator.activeSessionActivity("conv") + + assertNotNull(activity) + assertEquals("Meal Plan", activity!!.title) + assertTrue(activity.subtitle.contains("couldn't be built", ignoreCase = true)) + assertTrue(activity.suggestions.any { it.command == "generate recipes" }) + assertTrue(activity.suggestions.any { it.command == "change preferences" }) + assertTrue(activity.suggestions.any { it.command == "help" }) + assertTrue(activity.suggestions.any { it.command == "cancel plan" }) + assertFalse(activity.suggestions.any { it.command == "show current plan" }) + } + + @Test + fun `empty plan suggestions show recovery options not show current plan`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" + + // After failure, ingest another message to reach plan review handler + coordinator.ingestUserMessage("conv", "low lactose, chicken") + + // Now check the activity reflects recovery state + coEvery { sessionRepository.getActiveSession("conv") } returns failedPlan + val activity = coordinator.activeSessionActivity("conv") + + assertNotNull(activity) + assertTrue(activity!!.suggestions.any { it.command == "generate recipes" }) + assertTrue(activity.suggestions.any { it.command == "change preferences" }) + assertFalse(activity.suggestions.any { it.command == "show current plan" }) + } + + @Test + fun `old draft remains visible after rebuild failure with preference edit`() = runTest { + val snapshotWithDays = planReviewSnapshot() + // Simulate returning to slot collection with old days preserved + val collecting = collectingSnapshot().copy( + peopleCount = 4, + daysCount = 2, + days = snapshotWithDays.days, + ) + val updated = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, snapshotWithDays) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns updated + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns snapshotWithDays + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + // When days is not empty, failure message mentions previous draft + assertTrue(reply.content.contains("previous draft", ignoreCase = true)) + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } } private fun collectingSnapshot(): MealPlanSnapshot = MealPlanSnapshot( diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index 25f258e7b..8c7f67d74 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -1049,6 +1049,8 @@ class ChatViewModel @Inject constructor( MealPlannerSuggestionComposeMode.REPLACE -> onInputChanged(suggestion.command) MealPlannerSuggestionComposeMode.APPEND_COMMA -> onInputChanged(appendSmartReplyValue(_inputText.value, suggestion.command)) + MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING -> + onInputChanged(appendSmartReplyValue(stripNegationPrefixes(_inputText.value), suggestion.command)) } } @@ -1066,6 +1068,14 @@ class ChatViewModel @Inject constructor( "$trimmedCurrent, $command" } } + + private fun stripNegationPrefixes(current: String): String { + return current + .replace(Regex("no dietary requirements\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("no protein preferences?\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("no cuisine preferences?\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .trimEnd(',', ' ') + } /** Starts a one-shot voice capture: user speaks once, reply may be spoken, then loop ends. */ fun startVoiceInput() = startVoiceInput(VoiceMode.OneShot) diff --git a/lsp.json b/lsp.json index 9b8d18424..1708c0388 100644 --- a/lsp.json +++ b/lsp.json @@ -1,5 +1,6 @@ { "kotlin-lsp": { + "disabled": true, "warmupTimeoutMs": 90000 } }