feat: kitchen screen, scan dedup, gemini-3.5-flash-lite, seed grapes/beetroot/pea shoots
This commit is contained in:
+3
-1
@@ -44,8 +44,10 @@ AAMOS_MODE=mock
|
||||
AAMOS_API_URL=
|
||||
AAMOS_API_KEY=
|
||||
AAMOS_TIMEOUT_MS=60000
|
||||
# GEMINI_API_KEY lämnas tom lokalt – hämtas från AWS SSM (/cibello/prod/gemini-api-key) i prod,
|
||||
# inte inskriven för hand. Skriv aldrig over en .env som redan har riktiga varden.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.5-flash
|
||||
GEMINI_MODEL=gemini-3.5-flash-lite
|
||||
GEMINI_TIMEOUT_MS=60000
|
||||
GEMINI_DAILY_BUDGET_USD=25
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ pnpm install
|
||||
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d
|
||||
|
||||
# 3. Miljövariabler
|
||||
cp .env.example .env
|
||||
cp -n .env.example .env # -n = skriv inte over en befintlig .env
|
||||
|
||||
# 4. Migrera + seeda databasen
|
||||
pnpm db:migrate
|
||||
|
||||
@@ -48,7 +48,7 @@ const configSchema = z.object({
|
||||
AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||||
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
|
||||
GEMINI_MODEL: z.string().default("gemini-3.5-flash-lite"),
|
||||
GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||||
GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0),
|
||||
|
||||
@@ -104,8 +104,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "gemini" && !cfg.GEMINI_API_KEY) {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion.");
|
||||
if (
|
||||
cfg.AAMOS_MODE === "gemini" &&
|
||||
(!cfg.GEMINI_API_KEY || cfg.GEMINI_API_KEY.startsWith("ROTATE"))
|
||||
) {
|
||||
console.error(
|
||||
"SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver en riktig GEMINI_API_KEY (hämtas från SSM /cibello/prod/gemini-api-key), inte tom eller placeholder.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) {
|
||||
|
||||
@@ -150,6 +150,43 @@ export async function scanRoutes(app: FastifyInstance) {
|
||||
if (!locationId)
|
||||
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
|
||||
|
||||
// Dedup (#1): finns varan redan aktiv i hushållet? Uppdatera + hoppa över,
|
||||
// så överlappande foton / omfotografering inte skapar dubletter.
|
||||
const dedupCond = item.canonicalIngredientId
|
||||
? and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, item.canonicalIngredientId),
|
||||
gt(schema.inventoryItems.quantity, 0),
|
||||
)
|
||||
: and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
isNull(schema.inventoryItems.canonicalIngredientId),
|
||||
eq(schema.inventoryItems.displayName, item.displayName),
|
||||
gt(schema.inventoryItems.quantity, 0),
|
||||
);
|
||||
const [dupe] = await app.db
|
||||
.select({ id: schema.inventoryItems.id })
|
||||
.from(schema.inventoryItems)
|
||||
.where(dedupCond)
|
||||
.limit(1);
|
||||
if (dupe) {
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
brand: item.brand ?? null,
|
||||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||||
useByDate: item.useByDate ?? null,
|
||||
lastVerifiedAt: new Date(),
|
||||
verifiedByUser: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.inventoryItems.id, dupe.id));
|
||||
created.push(dupe.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
.values({
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -117,6 +117,9 @@ export default function HomeScreen() {
|
||||
<Card onPress={() => router.push("/saved-recipes")}>
|
||||
<Body>⭐ Dina sparade recept</Body>
|
||||
</Card>
|
||||
<Card onPress={() => router.push("/kitchen")}>
|
||||
<Body>🧺 Ditt kök</Body>
|
||||
</Card>
|
||||
|
||||
{budget.data && (
|
||||
<Card>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function WhatToEatScreen() {
|
||||
queryKey: ["what-to-eat", submittedCraving],
|
||||
queryFn: () =>
|
||||
api<WhatToEatResponse>(
|
||||
`/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
|
||||
`/v1/recommendations/what-to-eat?limit=15&view=pantry${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -76,15 +76,16 @@ export default function RootLayout() {
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="reconciliation" options={{ title: t("reconciliation.title") }} />
|
||||
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import * as Haptics from "expo-haptics";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Spacer,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/**
|
||||
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
|
||||
@@ -38,6 +39,13 @@ interface RecipeForCooking {
|
||||
temperatureC: number | null;
|
||||
tip: string | null;
|
||||
}>;
|
||||
ingredients: Array<{
|
||||
id: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function CookingScreen() {
|
||||
@@ -45,6 +53,7 @@ export default function CookingScreen() {
|
||||
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [showIngredients, setShowIngredients] = useState(false);
|
||||
const [timerLeft, setTimerLeft] = useState<number | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
@@ -265,6 +274,40 @@ export default function CookingScreen() {
|
||||
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
|
||||
</Text>
|
||||
{step?.tip && <Small>💡 {step.tip}</Small>}
|
||||
<Pressable
|
||||
onPress={() => setShowIngredients((v) => !v)}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
|
||||
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
|
||||
</Small>
|
||||
</Pressable>
|
||||
{showIngredients && (
|
||||
<View
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ScrollView>
|
||||
{recipe.ingredients.map((ing) => {
|
||||
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
|
||||
return (
|
||||
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{step?.timerSeconds != null && (
|
||||
<Pressable
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** Ditt kök: se och ta bort varor i lagret. Radering = mjuk (kvantitet → 0, historik bevaras). */
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
|
||||
}
|
||||
|
||||
export default function KitchenScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["inventory", "all"],
|
||||
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["inventory", "all"] });
|
||||
const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]);
|
||||
queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) =>
|
||||
old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old,
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _id, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{items.length === 0 && (
|
||||
<EmptyState text="Ditt kök är tomt än. Skanna eller lägg till varor." />
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<Button
|
||||
label="Ta bort"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
Alert.alert("Ta bort", `Ta bort ${item.displayName} ur ditt kök?`, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
{item.expiry.pastBestBefore && <Small>⚠︎ bäst före har passerat</Small>}
|
||||
</Card>
|
||||
))}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -86,17 +86,37 @@ export default function RecipeScreen() {
|
||||
const favorite = useMutation({
|
||||
mutationFn: (isFavorite: boolean) =>
|
||||
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
onMutate: async (isFavorite: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
|
||||
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
|
||||
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
|
||||
old ? { ...old, isFavorite: !isFavorite } : old,
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _isFavorite, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
const rate = useMutation({
|
||||
mutationFn: (stars: number) =>
|
||||
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
onMutate: async (stars: number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
|
||||
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
|
||||
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
|
||||
old ? { ...old, myRating: { stars } } : old,
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _stars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
@@ -214,14 +234,24 @@ export default function RecipeScreen() {
|
||||
<Card>
|
||||
<Heading>{t("cooked.rate")}</Heading>
|
||||
<Row>
|
||||
{[1, 2, 3, 4, 5].map((stars) => (
|
||||
<Button
|
||||
key={stars}
|
||||
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
|
||||
variant="ghost"
|
||||
onPress={() => rate.mutate(stars)}
|
||||
/>
|
||||
))}
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (recipe.myRating?.stars ?? 0) >= star;
|
||||
return (
|
||||
<Pressable
|
||||
key={star}
|
||||
onPress={() => rate.mutate(star)}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
})}
|
||||
>
|
||||
<Small style={{ fontSize: 34, color: filled ? colors.accent : colors.border }}>
|
||||
{filled ? "★" : "☆"}
|
||||
</Small>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
{recipe.ratingAverage != null && (
|
||||
<Small>
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"home.trustStatus.uncertain": "Lagret är osäkert – gör en snabbkoll",
|
||||
"reconciliation.title": "Snabbkoll av lagret",
|
||||
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
|
||||
"consent.title": "Innan du börjar",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna våra villkor. Bilderna du tar i appen (kylskåp, förpackningar, maträtter) används för att träna och förbättra Cibellos AI – det ingår i villkoren.",
|
||||
"consent.title": "Välkommen till Cibello",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna Användarvillkoren och Integritetspolicyn. I nästa steg väljer du själv hur personlig du vill att appen ska vara – helt frivilligt, och du kan ändra det när som helst.",
|
||||
"consent.termsLink": "Läs Användarvillkoren",
|
||||
"consent.privacyLink": "Läs Integritetspolicyn",
|
||||
"consent.checkbox": "Jag har läst och godkänner Användarvillkoren och Integritetspolicyn.",
|
||||
@@ -419,5 +419,21 @@
|
||||
"scan.meal.calories": "Uppskattade kalorier",
|
||||
"scan.meal.namePlaceholder": "Vad åt du? (valfritt)",
|
||||
"scan.meal.components": "Igenkänt på tallriken",
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot."
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot.",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ async function main() {
|
||||
],
|
||||
imageQualityIssues: [],
|
||||
},
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
latencyMs: 1234,
|
||||
costUsd: 0.0001,
|
||||
|
||||
@@ -301,19 +301,32 @@ function findBestCanonicalMatch(
|
||||
return best.item;
|
||||
}
|
||||
|
||||
function fold(s: string): string {
|
||||
// Vik bort accenter (crème -> creme) OCH å/ä/ö -> a/a/o så att plural-vokalskifte
|
||||
// (morot <-> morötter) och lånord (crème fraîche) matchar konsekvent.
|
||||
return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
function scoreMatch(query: string, item: CanonicalIndex): number {
|
||||
const candidates = [
|
||||
item.nameSv.toLowerCase(),
|
||||
item.nameEn.toLowerCase(),
|
||||
...item.aliases.map((a) => a.toLowerCase()),
|
||||
];
|
||||
const q = fold(query);
|
||||
const candidates = [item.nameSv, item.nameEn, ...item.aliases].map(fold);
|
||||
|
||||
let max = 0;
|
||||
const queryTokens = tokenize(query);
|
||||
const queryTokens = tokenize(q);
|
||||
|
||||
for (const cand of candidates) {
|
||||
if (cand === query) return 1;
|
||||
if (cand.includes(query) || query.includes(cand)) max = Math.max(max, 0.85);
|
||||
if (cand === q) return 1;
|
||||
if (cand.includes(q) || q.includes(cand)) {
|
||||
max = Math.max(max, 0.85);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delad prefix (vindruva <-> vindruvor): stark signal utan token-exakthet.
|
||||
const shorter = q.length <= cand.length ? q : cand;
|
||||
const longer = q.length <= cand.length ? cand : q;
|
||||
let p = 0;
|
||||
while (p < shorter.length && shorter[p] === longer[p]) p++;
|
||||
if (p >= 5 && p / shorter.length >= 0.7) max = Math.max(max, 0.7);
|
||||
|
||||
const candTokens = tokenize(cand);
|
||||
const intersection = queryTokens.filter((t) => candTokens.includes(t));
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("BUILD_TRAINING_SAMPLE banks locally", () => {
|
||||
corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" },
|
||||
},
|
||||
imageS3Key: "fridge-scans/train.jpg",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
consentSnapshot: { anonymized_improvement: "granted", image_training: "granted" },
|
||||
})
|
||||
|
||||
@@ -266,7 +266,7 @@ export class GeminiAamosClient implements AamosClient {
|
||||
constructor(config: GeminiAamosClientConfig) {
|
||||
this.cfg = {
|
||||
apiKey: config.apiKey,
|
||||
model: config.model ?? "gemini-2.5-flash",
|
||||
model: config.model ?? "gemini-3.5-flash-lite",
|
||||
timeoutMs: config.timeoutMs ?? 60_000,
|
||||
dailyBudgetUsd: config.dailyBudgetUsd ?? 0,
|
||||
promptVersion: config.promptVersion ?? "gemini-fridge-v1",
|
||||
@@ -856,6 +856,11 @@ Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med:
|
||||
- sistaForbruk: sista förbrukningsdatum om det syns (YYYY-MM-DD), annars null
|
||||
- konfidens: 0.0–1.0
|
||||
|
||||
Viktigt:
|
||||
- Kvantitet: uppskatta den FAKTISKA synliga/kvarvarande mängden, inte tryckta förpackningsantal. Ett tomt eller öppnat paket ska ha låg kvantitet (nära 0), även om lådan säger t.ex. "24-pack". Är du osäker, gissa lågt och sänk konfidensen.
|
||||
- Svenska mjölk- och mejerifärger: röd förpackning = standardmjölk 3%, grön = mellanmjölk 1,5%, blå = lättmjölk 0,5%. Använd färgen för att avgöra fetthalt.
|
||||
- Hellre lägre konfidens än en exakt gissning när du är osäker.
|
||||
|
||||
Svara ENDAST med giltig JSON i exakt detta format:
|
||||
{
|
||||
"items": [...],
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("GeminiAamosClient", () => {
|
||||
const budget = new MemoryBudgetStore();
|
||||
const client = new GeminiAamosClient({
|
||||
apiKey: "test-key",
|
||||
model: "gemini-2.5-flash",
|
||||
model: "gemini-3.5-flash-lite",
|
||||
fetchImpl: makeFetch(JPEG_BYTES),
|
||||
budgetStore: budget,
|
||||
dailyBudgetUsd: 10,
|
||||
@@ -82,7 +82,7 @@ describe("GeminiAamosClient", () => {
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.output).not.toBeNull();
|
||||
expect(result.modelVersion).toBe("gemini-2.5-flash");
|
||||
expect(result.modelVersion).toBe("gemini-3.5-flash-lite");
|
||||
expect(result.costUsd).toBeGreaterThan(0);
|
||||
expect(result.inputTokens).toBe(392);
|
||||
expect(result.outputTokens).toBe(121);
|
||||
|
||||
@@ -978,6 +978,9 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
ing("lingonberry_jam", "Lingonsylt", "Lingonberry jam", "skafferi", { n: { kcal: 200, p: 0.3, k: 49, f: 0.2, s: 45 }, isVegan: true, isVegetarian: true }),
|
||||
ing("prinskorv", "Prinskorv", "Cocktail sausage", "chark", { n: { kcal: 280, p: 11, k: 3, f: 25, mf: 9, salt: 2 }, isPork: true }),
|
||||
ing("rye_flour", "Rågmjöl", "Rye flour", "skafferi", { n: { kcal: 325, p: 9, k: 60, f: 2, fib: 14 }, containsGluten: true, allergens: ["gluten"], isVegan: true, isVegetarian: true }),
|
||||
ing("grape", "Vindruvor", "Grapes", "frukt", { n: { kcal: 69, p: 0.7, k: 18, f: 0.2, fib: 0.9, s: 15 }, isVegan: true, isVegetarian: true, aliases: ["druvor", "vindruva", "roda druvor", "röda druvor", "grona druvor", "gröna druvor", "drue druvor", "drue", "grapes"] }),
|
||||
ing("beetroot", "Rödbetor", "Beetroot", "gronsaker", { n: { kcal: 43, p: 1.6, k: 10, f: 0.2, fib: 2.8, s: 7 }, isVegan: true, isVegetarian: true, aliases: ["rödbeta", "rodbeta", "rödbetor", "rodbetor", "inlagda rödbetor", "inlagda rodbetor", "skivad rödbeta", "skivade rödbetor", "beetroot", "beets", "red beets"] }),
|
||||
ing("pea_shoots", "Ärtskott", "Pea shoots", "gronsaker", { n: { kcal: 42, p: 4, k: 4, f: 0.5, fib: 2 }, isVegan: true, isVegetarian: true, aliases: ["ärtskott", "artskott", "ertskott", "artiskott", "pea shoots"] })
|
||||
];
|
||||
|
||||
export const SEED_INGREDIENT_IDS = new Set(SEED_INGREDIENTS.map((i) => i.id));
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface PlanDefinition {
|
||||
googleProductId: string;
|
||||
}
|
||||
|
||||
export const TRIAL_DAYS = 7;
|
||||
export const TRIAL_DAYS = 14;
|
||||
export const TRIAL_SCANS = 25;
|
||||
|
||||
export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
free: {
|
||||
@@ -33,7 +34,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 0,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 1,
|
||||
aiScansPerMonth: 10,
|
||||
aiScansPerMonth: 5,
|
||||
weekPlanning: false,
|
||||
advancedNutrition: false,
|
||||
communityPublish: false,
|
||||
@@ -46,7 +47,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 7900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 3,
|
||||
aiScansPerMonth: 300,
|
||||
aiScansPerMonth: 100,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
@@ -59,7 +60,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 12900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 6,
|
||||
aiScansPerMonth: 600,
|
||||
aiScansPerMonth: 200,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
@@ -72,7 +73,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 16900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 12,
|
||||
aiScansPerMonth: 1000,
|
||||
aiScansPerMonth: 400,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
PLAN_DEFINITIONS,
|
||||
TRIAL_DAYS,
|
||||
TRIAL_SCANS,
|
||||
type Entitlements,
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionStatus,
|
||||
@@ -67,7 +68,7 @@ export function computeEntitlements(
|
||||
plan: "family",
|
||||
status: "trial",
|
||||
maxHouseholdMembers: def.maxHouseholdMembers,
|
||||
aiScansPerMonth: def.aiScansPerMonth,
|
||||
aiScansPerMonth: TRIAL_SCANS,
|
||||
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
|
||||
Reference in New Issue
Block a user