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:
Claude
2026-08-18 11:29:11 +00:00
parent e66d9aee72
commit 5cf9f8ab0d
2 changed files with 50 additions and 6 deletions
+33 -5
View File
@@ -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.01.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.01.0.
+17 -1
View File
@@ -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);
});
});