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:
Claude
2026-08-19 14:09:45 +00:00
parent dc5df13f25
commit de279a7c01
6 changed files with 225 additions and 37 deletions
+7 -2
View File
@@ -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}.`;
}
+84 -3
View File
@@ -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. */