fix(skan+sok): robust kvitto-enheter, titel-/fuzzy-sok, hogtidsfilter
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.
This commit is contained in:
@@ -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}.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>(UNITS);
|
||||
const UNIT_ALIASES: Record<string, string> = {
|
||||
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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user