Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 14 additions & 11 deletions apps/ios/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,16 @@ struct ContentView: View {
private struct HomeRecommendationRoute: Identifiable, Hashable, Sendable {
let ingredientIds: Set<Int64>
let ingredientNames: [String]
let preferredRecipeID: Int64?
let preferredID: ScoredRecipeID?

var id: String {
let recipePart = preferredRecipeID.map(String.init) ?? "none"
let recipePart: String
switch preferredID {
case .legacy(let legacyID): recipePart = "recipe-\(legacyID)"
case .catalog(let catalogID): recipePart = "catalog-\(catalogID)"
case .unsaved(let title): recipePart = "unsaved-\(title)"
case nil: recipePart = "none"
}
let ingredientPart = ingredientIds.sorted().map(String.init).joined(separator: ",")
return "\(recipePart)|\(ingredientPart)|\(ingredientNames.joined(separator: ","))"
}
Expand Down Expand Up @@ -352,7 +358,7 @@ struct ContentView: View {
RecipeResultsView(
ingredientIds: route.ingredientIds,
ingredientNames: route.ingredientNames,
preferredRecipeID: route.preferredRecipeID,
preferredID: route.preferredID,
logsMeals: true,
engine: deps.makeRecommendationEngine()
)
Expand Down Expand Up @@ -618,7 +624,7 @@ struct ContentView: View {
homeRecommendationRoute = HomeRecommendationRoute(
ingredientIds: recommendation.ingredientIDs,
ingredientNames: [],
preferredRecipeID: recommendation.recipeID
preferredID: recommendation.preferredID
)
}

Expand Down Expand Up @@ -736,15 +742,12 @@ struct ContentView: View {
return
}

let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)]
if let recipeID = recipe.recipe.id {
ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? []
} else {
ingredients = []
}
let context = LiveAssistantRecipeContextFactory(
recipeRepository: deps.recipeRepository
).context(for: recipe)

presentLiveAssistant(
recipeContext: LiveAssistantRecipeContext(scoredRecipe: recipe, ingredients: ingredients),
recipeContext: context,
replaySpotlightOnAppear: true,
marksTutorialProgressOnComplete: false
)
Expand Down
20 changes: 14 additions & 6 deletions apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,14 @@ final class GeminiCloudAgent: @unchecked Sendable {
"\($0.label):\(Int(($0.confidence * 100).rounded()))"
}.joined(separator: ", ")

let candidateSummary = candidates.prefix(8).compactMap { candidate -> String? in
guard let recipeID = candidate.recipe.recipe.id else { return nil }
// Every candidate rides under its handle — the legacy id when it has one, a
// negative alias when it is a catalog entry — so catalog recipes are ranked
// instead of being silently dropped, and the model's integer-keyed schema holds.
let handles = ReverseScanCandidateHandle.handles(for: candidates)
let candidateSummary = candidates.prefix(8).enumerated().compactMap { index, candidate -> String? in
guard index < handles.count else { return nil }
return
"id=\(recipeID), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)"
"id=\(handles[index]), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)"
}.joined(separator: "\n")

guard !candidateSummary.isEmpty else { return nil }
Expand Down Expand Up @@ -403,10 +407,14 @@ final class GeminiCloudAgent: @unchecked Sendable {
guard let baseURL = config.backendBaseURL else { return nil }
let endpoint = baseURL.appendingPathComponent("v1/reverse-scan/rank")

let candidatePayload = candidates.compactMap { candidate -> BackendReverseScanCandidate? in
guard let recipeID = candidate.recipe.recipe.id else { return nil }
// Catalog candidates ride under negative handles so the backend's integer
// recipe_id contract holds without a legacy row; the service maps rankings back
// through the same rule (ReverseScanCandidateHandle).
let handles = ReverseScanCandidateHandle.handles(for: candidates)
let candidatePayload = candidates.enumerated().compactMap { index, candidate -> BackendReverseScanCandidate? in
guard index < handles.count else { return nil }
return BackendReverseScanCandidate(
recipeId: recipeID,
recipeId: handles[index],
title: candidate.recipe.recipe.title,
localConfidence: candidate.confidenceScore,
missingRequiredCount: candidate.recipe.missingRequiredCount
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// The stable integer handle a reverse-scan candidate rides under in the cloud ranking
/// round trip. Both the direct Gemini prompt and the backend `/v1/reverse-scan/rank`
/// contract key rankings on an integer `recipe_id`; catalog candidates (no legacy row,
/// `Recipe(id: nil, …)`) keep their identity under a negative handle — legacy ids are
/// always positive, so the two spaces never collide. The agent assigns handles when it
/// builds the request and the service maps rankings back through the same rule, so the
/// pairing lives in one tested place, not in an agreed-on convention.
enum ReverseScanCandidateHandle {
/// The handle for each candidate, in the order the candidates will be sent. Legacy
/// recipes keep their positive row id; catalog candidates number from -1 in their own
/// counter — a global position offset would leave holes (-2, -4, …) between legacy
/// entries and make the handle space look sparser than it is to the ranking backend.
static func handles(for candidates: [ReverseScanRecipeCandidate]) -> [Int64] {
var catalogHandle = Int64(0)
return candidates.map { candidate in
guard let id = candidate.recipe.recipe.id else {
catalogHandle += 1
return -catalogHandle
}
return id
}
}
}
11 changes: 7 additions & 4 deletions apps/ios/Capability/Core/Services/ReverseScanService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,15 @@ final class ReverseScanService: Sendable {
),
!cloudRankings.isEmpty
{
let rankingByID = Dictionary(
let rankingByHandle = Dictionary(
uniqueKeysWithValues: cloudRankings.map { ($0.recipeID, $0) })

candidates = candidates.map { candidate in
guard let recipeID = candidate.recipe.recipe.id,
let cloud = rankingByID[recipeID]
// Rankings map back through the same handle rule the agent sent: the legacy
// id when the candidate has one, the negative alias when it is a catalog
// entry — so catalog candidates blend cloud confidence too.
let handles = ReverseScanCandidateHandle.handles(for: candidates)
candidates = candidates.enumerated().map { index, candidate in
guard index < handles.count, let cloud = rankingByHandle[handles[index]]
else {
return candidate
}
Expand Down
18 changes: 17 additions & 1 deletion apps/ios/Feature/Assistant/LiveAssistantModels.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FLFeatureLogic
import Foundation
import Observation

Expand All @@ -24,6 +25,15 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable {
let instructions: String
let ingredients: [LiveAssistantIngredientContext]
let matchedAt: Date
/// The catalog identity this session cooks at, when the recipe is a catalog recipe:
/// the cook session links the exact `(catalogId, revision)` that was cooked. nil keeps
/// the legacy integer-id session reference.
let catalogID: String?
let catalogRevision: Int?
/// The packet's structured steps — stated timers and thermometer checks — verbatim
/// from the installed bundle-v2 revision. nil for legacy recipes, which cook from the
/// parsed `instructions` text.
let catalogSteps: [CookStep]?

init(
recipeID: Int64?,
Expand All @@ -32,7 +42,10 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable {
servings: Int,
instructions: String,
ingredients: [LiveAssistantIngredientContext],
matchedAt: Date = .now
matchedAt: Date = .now,
catalogID: String? = nil,
catalogRevision: Int? = nil,
catalogSteps: [CookStep]? = nil
) {
self.id = recipeID.map(String.init) ?? UUID().uuidString
self.recipeID = recipeID
Expand All @@ -42,6 +55,9 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable {
self.instructions = instructions
self.ingredients = ingredients
self.matchedAt = matchedAt
self.catalogID = catalogID
self.catalogRevision = catalogRevision
self.catalogSteps = catalogSteps
}
}

Expand Down
56 changes: 56 additions & 0 deletions apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import FLFeatureLogic
import Foundation

/// Builds the LeChef recipe context for a scored result, over the same repository paths
/// the cooking guide and preview drawer use, so every surface that receives a
/// recommended recipe agrees on its content. A catalog result — the recommend pool
/// builds `Recipe(id: nil, …)` and carries the identity in the match's catalog ref —
/// resolves ingredients and structured steps from the catalog cook packet; a legacy
/// result keeps the legacy ingredient rows and free-text instructions. Extracted from
/// the views so tests exercise the exact production resolution.
struct LiveAssistantRecipeContextFactory {
let recipeRepository: RecipeRepository

func context(for scoredRecipe: ScoredRecipe) -> LiveAssistantRecipeContext {
let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)]
var catalogID: String?
var catalogRevision: Int?
var catalogSteps: [CookStep]?
var instructions = scoredRecipe.recipe.instructions

if let packet = try? recipeRepository.catalogCookPacket(for: scoredRecipe) {
// Catalog entries have no legacy row: the context cooks the packet — structured
// steps (timers, thermometer checks) and the packet's bridged ingredient lines.
ingredients = (try? recipeRepository.cookPacketIngredients(packet)) ?? []
catalogID = packet.catalogId
catalogRevision = packet.revision
catalogSteps = packet.steps
let stepText = packet.steps.map(\.text).joined(separator: "\n")
if !stepText.isEmpty {
instructions = stepText
}
} else if let recipeID = scoredRecipe.recipe.id {
ingredients = (try? recipeRepository.ingredientsForRecipe(id: recipeID)) ?? []
} else {
ingredients = []
}

return LiveAssistantRecipeContext(
recipeID: scoredRecipe.recipe.id,
title: scoredRecipe.recipe.title,
timeMinutes: scoredRecipe.recipe.timeMinutes,
servings: scoredRecipe.recipe.servings,
instructions: instructions,
ingredients: ingredients.map {
LiveAssistantIngredientContext(
name: $0.ingredient.name,
quantityText: $0.quantity.displayQuantity,
quantityGrams: $0.quantity.quantityGrams
)
},
catalogID: catalogID,
catalogRevision: catalogRevision,
catalogSteps: catalogSteps
)
}
}
16 changes: 14 additions & 2 deletions apps/ios/Feature/Assistant/LiveAssistantViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,20 @@ final class LiveAssistantViewModel: ObservableObject {

// The chat surface's cook session: in-memory, fail-closed on allergen knowledge
// (unknown blocks, LeChef §7), no meal logging — this is not the persisted cook-mode
// surface, but proposals still execute against a real session.
// surface, but proposals still execute against a real session. A catalog recipe
// runs the packet's structured steps and links the exact (catalogId, revision);
// legacy recipes cook from the parsed instructions and the integer-id reference.
var liveDependencies = CookModeViewModel.Dependencies.ephemeral()
liveDependencies.substituteIsAllergen = { _ in nil }
let catalogContent: CatalogCookContent?
if let catalogID = recipeContext.catalogID, let revision = recipeContext.catalogRevision,
let steps = recipeContext.catalogSteps, !steps.isEmpty
{
catalogContent = CatalogCookContent(
catalogId: catalogID, revision: revision, steps: steps)
} else {
catalogContent = nil
}
let cookViewModel = CookModeViewModel(
recipe: Recipe(
id: recipeContext.recipeID,
Expand All @@ -182,7 +193,8 @@ final class LiveAssistantViewModel: ObservableObject {
source: .bundled,
createdAt: nil),
logsMeal: false,
dependencies: liveDependencies)
dependencies: liveDependencies,
catalog: catalogContent)
self.cookViewModel = cookViewModel

let client = self.client
Expand Down
5 changes: 5 additions & 0 deletions apps/ios/Feature/Finalization/MealFinalizationSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ struct MealFinalizationSheet: View {
recipe: Recipe,
defaultServings: Int = 1,
deps: AppDependencies,
catalogId: String? = nil,
catalogRevision: Int? = nil,
onComplete: @escaping () -> Void
) {
_vm = State(
wrappedValue: MealFinalizationViewModel(
recipe: recipe,
defaultServings: defaultServings,
catalogId: catalogId,
catalogRevision: catalogRevision,
recipeRepository: deps.recipeRepository,
personalizationService: deps.personalizationService,
inventoryRepository: deps.inventoryRepository
))
Expand Down
25 changes: 20 additions & 5 deletions apps/ios/Feature/Finalization/MealFinalizationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,30 @@ final class MealFinalizationViewModel {
// MARK: - Context

let recipe: Recipe
/// The catalog identity this meal cooked at, when the recipe is a catalog recipe:
/// the log links the exact `(catalogId, revision)`, so later revision bumps never
/// rewrite its nutrition. nil logs the legacy integer-id row.
private let catalogId: String?
private let catalogRevision: Int?

private let recipeRepository: RecipeRepository
private let personalizationService: PersonalizationService
private let inventoryRepository: InventoryRepository

init(
recipe: Recipe,
defaultServings: Int = 1,
catalogId: String? = nil,
catalogRevision: Int? = nil,
recipeRepository: RecipeRepository,
personalizationService: PersonalizationService,
inventoryRepository: InventoryRepository
) {
self.recipe = recipe
self.servings = defaultServings
self.catalogId = catalogId
self.catalogRevision = catalogRevision
self.recipeRepository = recipeRepository
self.personalizationService = personalizationService
self.inventoryRepository = inventoryRepository
}
Expand All @@ -46,17 +58,20 @@ final class MealFinalizationViewModel {
defer { isSaving = false }

do {
guard let recipeId = recipe.id else {
errorMessage = "Recipe has no ID."
return
}
// Catalog entries carry no integer recipe id; the log lands on the legacy row the
// meal resolves to (a title match, or a minimal row when none exists), and links
// the (catalogId, revision) that was cooked so nutrition reports from the frozen
// per-revision snapshot. Legacy recipes keep their integer id unchanged.
let recipeId = try recipe.id ?? recipeRepository.resolvePersistedRecipeID(for: recipe)

try personalizationService.recordCooking(
recipeId: recipeId,
rating: rating > 0 ? rating : nil,
imagePath: photoPath,
servingsConsumed: servings,
recipeSource: TasteRecipeSource(recipeSource: recipe.source)
recipeSource: TasteRecipeSource(recipeSource: recipe.source),
catalogId: catalogId,
catalogRevision: catalogRevision
)

try inventoryRepository.applyConsumption(
Expand Down
12 changes: 10 additions & 2 deletions apps/ios/Feature/Home/HomeDashboardModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ struct WeekdayCookingPoint: Identifiable, Sendable, Equatable {
}

struct HomeRecommendation: Identifiable, Sendable {
let recipeID: Int64?
/// The scored identity the card opens — a catalog ref when the recommendation is a
/// catalog recipe, the legacy integer id otherwise — so the Results screen
/// auto-presents this exact result instead of guessing it from list order.
let preferredID: ScoredRecipeID?
let recipeName: String
let explanation: String
let cookTimeMinutes: Int?
Expand All @@ -25,7 +28,12 @@ struct HomeRecommendation: Identifiable, Sendable {
let ingredientIDs: Set<Int64>

var id: String {
recipeID.map(String.init) ?? recipeName
switch preferredID {
case .legacy(let legacyID): return "recipe-\(legacyID)"
case .catalog(let catalogID): return "catalog-\(catalogID)"
case .unsaved(let title): return "unsaved-\(title)"
case nil: return recipeName
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions apps/ios/Feature/Home/HomeDashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ final class HomeDashboardViewModel: ObservableObject {
now: Date())
if let top = all.first {
primary = HomeRecommendation(
recipeID: top.recipe.id,
preferredID: top.id,
recipeName: top.recipe.title,
explanation: top.rankingReasons.prefix(3).joined(separator: ". ") + ".",
cookTimeMinutes: top.recipe.timeMinutes,
Expand All @@ -113,7 +113,7 @@ final class HomeDashboardViewModel: ObservableObject {
}
fallbacks = Array(all.dropFirst().prefix(3)).map { scored in
HomeRecommendation(
recipeID: scored.recipe.id,
preferredID: scored.id,
recipeName: scored.recipe.title,
explanation: scored.rankingReasons.prefix(2).joined(separator: ". "),
cookTimeMinutes: scored.recipe.timeMinutes,
Expand Down
Loading
Loading