diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts index 780f857..5744786 100644 --- a/packages/ai-contracts/src/gemini.ts +++ b/packages/ai-contracts/src/gemini.ts @@ -260,6 +260,33 @@ function normalizeUnit(unit: string | null | undefined): string | null { return null; } +const MAX_PAST_DAYS = 550; // ~18 mån bakåt: verkligt passerade varor tillåts, men grovt feltolkade årtal inte. +const MAX_FUTURE_DAYS = 1830; // ~5 år framåt: torrvaror kan ha långt bäst-före; längre än så är en feltolkning. + +/** + * Rimlighetsspärr för AI-lästa matvarudatum. Ett datum som ligger orimligt långt + * bak eller fram i tiden är nästan alltid en feltolkad stämpelkod (t.ex. ett gissat + * årtal). Hellre inget datum än ett felaktigt — annars kan appen visa en falsk + * "utgången"/"går ut snart"-varning (spec §13). Format-parsning görs av parseDate; + * detta filtrerar bara bort grova orimligheter, inte verkligt nyligen passerade varor. + */ +export function isPlausibleFoodDate( + dateStr: string | null | undefined, + today: Date = new Date(), +): boolean { + if (!dateStr) return false; + const d = new Date(`${dateStr}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return false; + const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); + const daysDiff = (d.getTime() - todayUtc) / 86_400_000; + return daysDiff >= -MAX_PAST_DAYS && daysDiff <= MAX_FUTURE_DAYS; +} + +/** parseDate + rimlighetsspärr: returnerar datumet bara om det är rimligt, annars null. */ +function plausibleFoodDate(dateStr: string | null | undefined, today: Date = new Date()): string | null { + return isPlausibleFoodDate(dateStr, today) ? (dateStr ?? null) : null; +} + function toDetectedItems(parsed: z.infer): DetectedItem[] { return parsed.items.map((item) => { const { quantity, unit } = parseQuantity(item.kvantitet); @@ -269,7 +296,7 @@ function toDetectedItems(parsed: z.infer): De brand: item.varumarke, estimatedQuantity: quantity, unit: normalizeUnit(unit ?? item.enhet), - bestBeforeDate: parseDate(item.bastaFore ?? item.sistaForbruk), + bestBeforeDate: plausibleFoodDate(parseDate(item.bastaFore ?? item.sistaForbruk)), confidence: item.konfidens, requiresConfirmation: item.konfidens < 0.92, boundingBox: null, @@ -289,7 +316,7 @@ export class GeminiAamosClient implements AamosClient { model: config.model ?? "gemini-3.5-flash-lite", timeoutMs: config.timeoutMs ?? 60_000, dailyBudgetUsd: config.dailyBudgetUsd ?? 0, - promptVersion: config.promptVersion ?? "gemini-fridge-v1", + promptVersion: config.promptVersion ?? "gemini-fridge-v2", fetchImpl: config.fetchImpl ?? customNodeFetch, budgetStore: config.budgetStore, }; @@ -872,13 +899,14 @@ Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med: - kvantitet: siffra (t.ex. 1, 500, 1.5) - enhet: förkortning som l, g, ml, st, förpackning - kategori: t.ex. mejeri, frukt, grönt, kött, skafferi -- bastaFore: bäst-före-datum om det syns (YYYY-MM-DD), annars null -- sistaForbruk: sista förbrukningsdatum om det syns (YYYY-MM-DD), annars null +- bastaFore: bäst-före-datum ENDAST om det står tydligt och fullständigt utskrivet med årtal (YYYY-MM-DD), annars null +- sistaForbruk: sista förbrukningsdatum ENDAST om det står tydligt och fullständigt utskrivet med årtal (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. +- Datum: gissa ALDRIG årtal eller datum. Många förpackningar har kryptiska sifferkoder (tillverknings-/partinummer) som INTE är bäst-före-datum. Ange ett datum bara om det står klart och otvetydigt vilket datum OCH vilket årtal som avses. Är koden tvetydig, delvis dold eller kan läsas på flera sätt → returnera null. Ett påhittat årtal är ett FEL. Hellre null än ett gissat datum. - Hellre lägre konfidens än en exakt gissning när du är osäker. Svara ENDAST med giltig JSON i exakt detta format: @@ -962,7 +990,7 @@ Språk: ${lang}.`; return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Bilden visar ett bäst-före-datum eller sista förbrukningsdatum. Returnera: -- date: datum i format YYYY-MM-DD, eller null om ej läsbart. +- date: datum i format YYYY-MM-DD, ENDAST om det står klart och otvetydigt inklusive årtal. Gissa aldrig årtalet. Är koden kryptisk, delvis dold eller tvetydig → null. Hellre null än ett gissat datum. - dateKind: "best_before" för "bäst före", "use_by" för "sista förbrukningsdag", annars null. - confidence: 0.0–1.0. diff --git a/packages/ai-contracts/test/gemini.test.ts b/packages/ai-contracts/test/gemini.test.ts index 97de31a..b6ffe77 100644 --- a/packages/ai-contracts/test/gemini.test.ts +++ b/packages/ai-contracts/test/gemini.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { GeminiAamosClient, MemoryBudgetStore, parseDate } from "../src/gemini.js"; +import { GeminiAamosClient, MemoryBudgetStore, parseDate, isPlausibleFoodDate } from "../src/gemini.js"; import type { AamosRequestEnvelope } from "../src/tasks.js"; const FIXTURE_RESPONSE = { @@ -170,3 +170,19 @@ describe("parseDate (svenska bäst-före-stämplar)", () => { expect(parseDate("skräp")).toBeNull(); }); }); + +describe("isPlausibleFoodDate (rimlighetsspärr mot feltolkade årtal)", () => { + const TODAY = new Date("2026-08-18T00:00:00Z"); + it("släpper igenom rimliga datum (nära i tid och framåt)", () => { + expect(isPlausibleFoodDate("2026-09-04", TODAY)).toBe(true); // ~2 v framåt + expect(isPlausibleFoodDate("2026-08-01", TODAY)).toBe(true); // nyss passerat + expect(isPlausibleFoodDate("2027-06-01", TODAY)).toBe(true); // torrvara framåt + }); + it("kastar orimliga datum – grovt feltolkat årtal", () => { + // Grädden i fälttestet: stämpel lästes som 2024 → ~2 år bak = feltolkning, inte utgången vara. + expect(isPlausibleFoodDate("2024-09-26", TODAY)).toBe(false); + expect(isPlausibleFoodDate("2035-01-01", TODAY)).toBe(false); // orimligt långt fram + expect(isPlausibleFoodDate(null, TODAY)).toBe(false); + expect(isPlausibleFoodDate("skräp", TODAY)).toBe(false); + }); +});