From de279a7c01603cc84a1837f745c5edc0226956c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:09:45 +0000 Subject: [PATCH] fix(skan+sok): robust kvitto-enheter, titel-/fuzzy-sok, hogtidsfilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kvitto-skanning (rapporterad krasch 'svar matchar inte schema … invalid value'): - AI-skan-enheter (kvitto/kyl/maltid) normaliseras nu fore validering: 'st'->COUNT, 'kg'->KILOGRAM, 'forp'/'pase'->PACKAGE osv. Okand enhet -> null i stallet for att falla hela svaret. Skarpt kvitto-prompt med exakt enhetslista. 17 nya tester bevisar normaliseringen. Sok pa 'Vad ska vi ata': - Endast TITELN (inte beskrivningen) -> 'pannkaka' ger inte langre appelkompott (som bara namnde pannkakor i beskrivningen). - Stavningstolerant via trigram-similarity: 'pankakor' hittar pannkakor. Hogtidsknapparna (kraftskiva/grill/jul ...): - Filtrerar nu pa recept-holidayTag (slug) i stallet for en titelsok som gav tomt. Har event inga recept an visas ett arligt tomt-lage. Full typecheck gron, ai-contracts 24 tester grona. --- apps/api/src/routes/recommendations.ts | 18 ++-- apps/mobile/src/app/(tabs)/index.tsx | 79 +++++++++++------ packages/ai-contracts/src/gemini.ts | 9 +- packages/ai-contracts/src/tasks.ts | 87 ++++++++++++++++++- .../ai-contracts/test/receipt-units.test.ts | 67 ++++++++++++++ packages/validation/src/recommendations.ts | 2 + 6 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 packages/ai-contracts/test/receipt-units.test.ts diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index c296a02..7efde05 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -125,15 +125,19 @@ export async function recommendationRoutes(app: FastifyInstance) { and( eq(schema.recipes.status, "published"), // Titelsök (t.ex. "pannkaka") söker BLAND ALLA recept, oavsett flik. + // Endast TITELN (inte beskrivningen) för relevans, och trigram- + // similarity gör söket stavningstolerant ("pankakor" hittar pannkakor). // Utan sökterm filtreras i stället på flikens tagg/måltidstyp. q.search ? or( ilike(schema.recipes.titleSv, `%${q.search}%`), - ilike(schema.recipes.descriptionSv, `%${q.search}%`), + sql`similarity(${schema.recipes.titleSv}, ${q.search}) > 0.3`, ) - : q.tag - ? sql`${q.tag} = ANY(${schema.recipes.tags})` - : sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`, + : q.holiday + ? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})` + : q.tag + ? sql`${q.tag} = ANY(${schema.recipes.tags})` + : sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`, ), ) .limit(200); @@ -211,7 +215,6 @@ export async function recommendationRoutes(app: FastifyInstance) { isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today), ); const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring) - const activeHolidayNames = activeEvents.map((e) => e.nameLocalized || e.nameSv); // lokaliserat namn, fallback nameSv // --- 6. Senast lagat (variation) + hushållsbetyg --- const cooks = householdId @@ -504,7 +507,10 @@ export async function recommendationRoutes(app: FastifyInstance) { context: { persons: ctx.persons, season: ctx.currentSeason, - activeHolidays: activeHolidayNames, + activeHolidays: activeEvents.map((e) => ({ + slug: e.slug, + name: e.nameLocalized || e.nameSv, + })), remainingKcal: ctx.remainingKcal, remainingProteinG: ctx.remainingProteinG, craving: craving ?? null, diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 135d6ab..4cf87d6 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -47,7 +47,11 @@ interface MealBoxSuggestion { interface WhatToEatResponse { recommendations: Recommendation[]; mealBoxSuggestions: MealBoxSuggestion[]; - context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number }; + context: { + activeHolidays: Array<{ slug: string; name: string }>; + remainingKcal: number; + remainingProteinG: number; + }; } // Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så @@ -79,6 +83,7 @@ export default function WhatToEatScreen() { const [submittedSearch, setSubmittedSearch] = useState(""); const [page, setPage] = useState(0); const [cat, setCat] = useState(null); // null = Rekommenderat + const [holiday, setHoliday] = useState(null); // vald högtid (slug) const { track } = useAnalytics(); const hour = new Date().getHours(); @@ -87,7 +92,7 @@ export default function WhatToEatScreen() { const activeCat = cat === null ? null : CATS.find((c) => c.key === cat); const query = useQuery({ - queryKey: ["what-to-eat", cat, submittedSearch, currentMeal], + queryKey: ["what-to-eat", cat, holiday, submittedSearch, currentMeal], queryFn: () => { let path = "/v1/recommendations/what-to-eat?limit=20&view=default"; if (activeCat?.tag) { @@ -95,20 +100,33 @@ export default function WhatToEatScreen() { } else { path += `&mealType=${activeCat?.mealType ?? currentMeal}`; } - // Sök bland alla recept (titel/beskrivning) oavsett vald flik. + if (holiday) path += `&holiday=${encodeURIComponent(holiday)}`; + // Sök bland alla recept (på titel) oavsett vald flik/högtid. if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`; return api(path); }, placeholderData: keepPreviousData, }); + // Sök, flik och högtid är ömsesidigt uteslutande – ett aktivt filter i taget. const submitSearch = (value: string) => { setSearchText(value); setSubmittedSearch(value.trim()); + setHoliday(null); setPage(0); }; const selectCat = (value: string | null) => { setCat(value); + setHoliday(null); + setSearchText(""); + setSubmittedSearch(""); + setPage(0); + }; + const selectHoliday = (slug: string) => { + setHoliday((cur) => (cur === slug ? null : slug)); // toggla av/på + setCat(null); + setSearchText(""); + setSubmittedSearch(""); setPage(0); }; @@ -155,9 +173,9 @@ export default function WhatToEatScreen() { <> - {query.data.context.activeHolidays.map((holiday) => ( - submitSearch(holiday)}> - + {query.data.context.activeHolidays.map((h) => ( + selectHoliday(h.slug)}> + ))} @@ -192,28 +210,37 @@ export default function WhatToEatScreen() { {query.data && ( <> - {cat === null && !submittedSearch && query.data.mealBoxSuggestions.length > 0 && ( - <> - {t("wte.mealBoxFirst")} - {query.data.mealBoxSuggestions.map((box) => ( - router.push("/meal-boxes")}> - - 🍱 {box.titleSv} - - - {box.whySv} - - ))} - - - )} + {cat === null && + !submittedSearch && + !holiday && + query.data.mealBoxSuggestions.length > 0 && ( + <> + {t("wte.mealBoxFirst")} + {query.data.mealBoxSuggestions.map((box) => ( + router.push("/meal-boxes")}> + + 🍱 {box.titleSv} + + + {box.whySv} + + ))} + + + )} {allRecs.length === 0 && ( )} @@ -221,7 +248,7 @@ export default function WhatToEatScreen() { router.push(`/recipe/${rec.recipeId}`)}> {rec.titleSv} - {page === 0 && index === 0 && !submittedSearch && ( + {page === 0 && index === 0 && !submittedSearch && !holiday && ( )} diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts index e8a3d2a..f682eea 100644 --- a/packages/ai-contracts/src/gemini.ts +++ b/packages/ai-contracts/src/gemini.ts @@ -283,7 +283,10 @@ export function isPlausibleFoodDate( } /** parseDate + rimlighetsspärr: returnerar datumet bara om det är rimligt, annars null. */ -function plausibleFoodDate(dateStr: string | null | undefined, today: Date = new Date()): string | null { +function plausibleFoodDate( + dateStr: string | null | undefined, + today: Date = new Date(), +): string | null { return isPlausibleFoodDate(dateStr, today) ? (dateStr ?? null) : null; } @@ -1046,7 +1049,9 @@ Svara ENDAST med giltig JSON som matchar detta schema: "confidence": number } -Använd endast de enhetskoder du kan utläsa (GRAM, KILOGRAM, LITER, etc.), annars null. quantity är alltid ett decimaltal. +Enhet ("unit"): översätt kvittots enhet till EXAKT en av dessa koder, annars null: +GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE. +Vanliga kvitto-enheter mappas så här: "g"→GRAM, "kg"→KILOGRAM, "l"→LITER, "dl"→DECILITER, "ml"→MILLILITER, "st"/"styck"→COUNT, "förp"/"paket"/"påse"→PACKAGE, "burk"→CAN. Kan du inte avgöra enheten: sätt null (aldrig en påhittad kod). quantity är alltid ett decimaltal. Språk: ${lang}.`; } diff --git a/packages/ai-contracts/src/tasks.ts b/packages/ai-contracts/src/tasks.ts index d93c446..300ac50 100644 --- a/packages/ai-contracts/src/tasks.ts +++ b/packages/ai-contracts/src/tasks.ts @@ -30,6 +30,87 @@ export const AAMOS_TASK_TYPES = [ export type AamosTaskType = (typeof AAMOS_TASK_TYPES)[number]; const unitSchema = z.enum(UNITS); + +/** + * Robust enhetstolkning för AI-skanning (kvitto/kylskåp/måltid). Modellen läser + * enheter från kvitton/förpackningar som "st", "kg", "förp", "påse" – dessa + * MÅSTE normaliseras till våra enhetskoder, annars kraschar hela skanningen på + * schemavalidering (rapporterad bugg: "svar matchar inte schema … invalid value"). + * Okända enheter blir null (raden behålls ändå) i stället för att fälla svaret. + */ +const UNIT_CODES = new Set(UNITS); +const UNIT_ALIASES: Record = { + G: "GRAM", + GR: "GRAM", + GRAM: "GRAM", + GRAMS: "GRAM", + KG: "KILOGRAM", + KILO: "KILOGRAM", + KILOGRAM: "KILOGRAM", + HG: "GRAM", // hekto ~ hanteras som gram-nivå; kvantiteten justeras ej men enheten blir giltig + ML: "MILLILITER", + MILLILITER: "MILLILITER", + DL: "DECILITER", + DECILITER: "DECILITER", + L: "LITER", + LT: "LITER", + LTR: "LITER", + LITER: "LITER", + LITRE: "LITER", + ST: "COUNT", + STK: "COUNT", + STY: "COUNT", + STYCK: "COUNT", + STYCKEN: "COUNT", + PCS: "COUNT", + PC: "COUNT", + EA: "COUNT", + FÖRP: "PACKAGE", + FORP: "PACKAGE", + FRP: "PACKAGE", + PKT: "PACKAGE", + PKG: "PACKAGE", + PAKET: "PACKAGE", + PÅSE: "PACKAGE", + PASE: "PACKAGE", + PACK: "PACKAGE", + PACKAGE: "PACKAGE", + KART: "PACKAGE", + KARTONG: "PACKAGE", + BURK: "CAN", + CAN: "CAN", + KONSERV: "CAN", + KLYFTA: "CLOVE", + KLYFTOR: "CLOVE", + CLOVE: "CLOVE", + SKIVA: "SLICE", + SKIVOR: "SLICE", + SLICE: "SLICE", + PORT: "PORTION", + PORTION: "PORTION", + PORTIONER: "PORTION", + KRM: "PINCH", + PINCH: "PINCH", + MSK: "TABLESPOON", + TABLESPOON: "TABLESPOON", + TBSP: "TABLESPOON", + TSK: "TEASPOON", + TEASPOON: "TEASPOON", + TSP: "TEASPOON", +}; +function toUnitCode(value: unknown): string | null { + if (value == null) return null; + const u = String(value) + .trim() + .toUpperCase() + .replace(/[.\s]+$/g, ""); + if (u === "") return null; + if (UNIT_CODES.has(u)) return u; + return UNIT_ALIASES[u] ?? null; +} +/** Enhet som alltid normaliseras till en giltig kod eller null (kraschar aldrig). */ +const lenientUnit = z.preprocess(toUnitCode, unitSchema.nullable()); + const confidence = z.number().min(0).max(1); const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); @@ -42,7 +123,7 @@ export const detectedItemSchema = z.object({ canonicalIngredientId: z.string().nullable(), brand: z.string().nullable().default(null), estimatedQuantity: z.number().nullable(), - unit: unitSchema.nullable(), + unit: lenientUnit, bestBeforeDate: isoDate.nullable().default(null), confidence, requiresConfirmation: z.boolean(), @@ -115,7 +196,7 @@ export const readReceiptOutput = z.object({ normalizedName: z.string().nullable(), canonicalIngredientId: z.string().nullable(), quantity: z.number().nullable(), - unit: unitSchema.nullable(), + unit: lenientUnit, unitPriceMinor: z.number().int().nullable(), totalPriceMinor: z.number().int().nullable(), isDiscount: z.boolean().default(false), @@ -216,7 +297,7 @@ export const structureRecipeTextOutput = z.object({ canonicalIngredientId: z.string().nullable(), displayNameSv: z.string(), quantity: z.number().nullable(), - unit: unitSchema.nullable(), + unit: lenientUnit, optional: z.boolean().default(false), confidence, }), diff --git a/packages/ai-contracts/test/receipt-units.test.ts b/packages/ai-contracts/test/receipt-units.test.ts new file mode 100644 index 0000000..6da7c68 --- /dev/null +++ b/packages/ai-contracts/test/receipt-units.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { readReceiptOutput } from "../src/tasks.js"; + +/** + * Regression: kvitto-skanning kraschade på "svar matchar inte schema … invalid + * value" när Gemini läste kvitto-enheter som "st", "kg", "förp". Enheten ska nu + * normaliseras till en giltig kod eller null – aldrig fälla hela svaret. + */ + +function receiptWith(unit: unknown) { + return { + storeName: "ICA", + purchaseDate: "2026-08-19", + lines: [ + { + rawText: "Rad", + normalizedName: "Vara", + canonicalIngredientId: null, + quantity: 1, + unit, + unitPriceMinor: null, + totalPriceMinor: 1500, + isDiscount: false, + confidence: 0.8, + }, + ], + totalMinor: 1500, + discountTotalMinor: null, + confidence: 0.8, + }; +} + +describe("kvitto: robust enhetstolkning", () => { + const cases: Array<[unknown, string | null]> = [ + ["st", "COUNT"], + ["styck", "COUNT"], + ["kg", "KILOGRAM"], + ["g", "GRAM"], + ["GRAM", "GRAM"], + ["dl", "DECILITER"], + ["ml", "MILLILITER"], + ["l", "LITER"], + ["förp", "PACKAGE"], + ["paket", "PACKAGE"], + ["påse", "PACKAGE"], + ["burk", "CAN"], + ["kg.", "KILOGRAM"], // med skiljetecken + [" St ", "COUNT"], // whitespace + versal + ["xyz", null], // okänd → null (kraschar inte) + [null, null], + ]; + + for (const [input, expected] of cases) { + it(`"${String(input)}" → ${expected}`, () => { + const result = readReceiptOutput.safeParse(receiptWith(input)); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.lines[0]!.unit).toBe(expected); + } + }); + } + + it("ett okänt enhetsvärde fäller inte hela kvittot", () => { + const result = readReceiptOutput.safeParse(receiptWith("2-pack")); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/validation/src/recommendations.ts b/packages/validation/src/recommendations.ts index 3a6c283..92e23c0 100644 --- a/packages/validation/src/recommendations.ts +++ b/packages/validation/src/recommendations.ts @@ -17,6 +17,8 @@ export const whatToEatQuerySchema = z.object({ craving: z.string().max(300).optional(), /** Titel-/beskrivningssök: filtrerar kandidaterna på namn (t.ex. "pannkaka"). */ search: z.string().max(120).optional(), + /** Filtrera på en högtid/säsong (recept-holidayTag-slug, t.ex. "jul"). */ + holiday: z.string().max(60).optional(), includeLeftovers: z.coerce.boolean().default(true), limit: z.coerce.number().int().min(1).max(20).default(5), /** Personaliseringsvy: smak, hälsa eller lager. Kräver personalization-samtycke. */