fix(ai): las aldrig gissade utgangsdatum – skarpt prompt + rimlighetssparr
Gemini gissade fel artal pa kryptiska tryckstamplar (t.ex. "2024" pa en farsk gradde) vilket kan trigga falska "utgangen"-varningar. Tva lager: - Prompt: modellen far aldrig gissa artal, returnerar null vid tvetydiga/ kryptiska koder (buildFridgePrompt + buildExpiryPrompt). - Kod: ny isPlausibleFoodDate() nollar orimligt gamla/framtida datum i toDetectedItems. Hellre inget datum an ett felaktigt (spec §13). promptVersion -> gemini-fridge-v2. Tester 7/7 grona. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WZdew6cWn1MeHqoYWFfxzo
This commit is contained in:
@@ -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<typeof geminiFridgeResponseSchema>): DetectedItem[] {
|
||||
return parsed.items.map((item) => {
|
||||
const { quantity, unit } = parseQuantity(item.kvantitet);
|
||||
@@ -269,7 +296,7 @@ function toDetectedItems(parsed: z.infer<typeof geminiFridgeResponseSchema>): 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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user