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
+12 -6
View File
@@ -125,15 +125,19 @@ export async function recommendationRoutes(app: FastifyInstance) {
and(
eq(schema.recipes.status, "published"),
// Titelsök (t.ex. "pannkaka") söker BLAND ALLA recept, oavsett flik.
// Endast TITELN (inte beskrivningen) för relevans, och trigram-
// similarity gör söket stavningstolerant ("pankakor" hittar pannkakor).
// Utan sökterm filtreras i stället på flikens tagg/måltidstyp.
q.search
? or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
sql`similarity(${schema.recipes.titleSv}, ${q.search}) > 0.3`,
)
: q.tag
? sql`${q.tag} = ANY(${schema.recipes.tags})`
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
: q.holiday
? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})`
: q.tag
? sql`${q.tag} = ANY(${schema.recipes.tags})`
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
),
)
.limit(200);
@@ -211,7 +215,6 @@ export async function recommendationRoutes(app: FastifyInstance) {
isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today),
);
const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring)
const activeHolidayNames = activeEvents.map((e) => e.nameLocalized || e.nameSv); // lokaliserat namn, fallback nameSv
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = householdId
@@ -504,7 +507,10 @@ export async function recommendationRoutes(app: FastifyInstance) {
context: {
persons: ctx.persons,
season: ctx.currentSeason,
activeHolidays: activeHolidayNames,
activeHolidays: activeEvents.map((e) => ({
slug: e.slug,
name: e.nameLocalized || e.nameSv,
})),
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
+53 -26
View File
@@ -47,7 +47,11 @@ interface MealBoxSuggestion {
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
context: {
activeHolidays: Array<{ slug: string; name: string }>;
remainingKcal: number;
remainingProteinG: number;
};
}
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
@@ -79,6 +83,7 @@ export default function WhatToEatScreen() {
const [submittedSearch, setSubmittedSearch] = useState("");
const [page, setPage] = useState(0);
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
const [holiday, setHoliday] = useState<string | null>(null); // vald högtid (slug)
const { track } = useAnalytics();
const hour = new Date().getHours();
@@ -87,7 +92,7 @@ export default function WhatToEatScreen() {
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
const query = useQuery({
queryKey: ["what-to-eat", cat, submittedSearch, currentMeal],
queryKey: ["what-to-eat", cat, holiday, submittedSearch, currentMeal],
queryFn: () => {
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
if (activeCat?.tag) {
@@ -95,20 +100,33 @@ export default function WhatToEatScreen() {
} else {
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
}
// Sök bland alla recept (titel/beskrivning) oavsett vald flik.
if (holiday) path += `&holiday=${encodeURIComponent(holiday)}`;
// Sök bland alla recept (på titel) oavsett vald flik/högtid.
if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`;
return api<WhatToEatResponse>(path);
},
placeholderData: keepPreviousData,
});
// Sök, flik och högtid är ömsesidigt uteslutande ett aktivt filter i taget.
const submitSearch = (value: string) => {
setSearchText(value);
setSubmittedSearch(value.trim());
setHoliday(null);
setPage(0);
};
const selectCat = (value: string | null) => {
setCat(value);
setHoliday(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
const selectHoliday = (slug: string) => {
setHoliday((cur) => (cur === slug ? null : slug)); // toggla av/på
setCat(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
@@ -155,9 +173,9 @@ export default function WhatToEatScreen() {
<>
<Spacer size={spacing.xs} />
<Row style={{ flexWrap: "wrap" }}>
{query.data.context.activeHolidays.map((holiday) => (
<Pressable key={holiday} onPress={() => submitSearch(holiday)}>
<Tag label={`🎉 ${holiday}`} tone="accent" />
{query.data.context.activeHolidays.map((h) => (
<Pressable key={h.slug} onPress={() => selectHoliday(h.slug)}>
<Tag label={`🎉 ${h.name}`} tone={holiday === h.slug ? "success" : "accent"} />
</Pressable>
))}
</Row>
@@ -192,28 +210,37 @@ export default function WhatToEatScreen() {
{query.data && (
<>
{cat === null && !submittedSearch && query.data.mealBoxSuggestions.length > 0 && (
<>
<Heading>{t("wte.mealBoxFirst")}</Heading>
{query.data.mealBoxSuggestions.map((box) => (
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {box.titleSv}</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Small>{box.whySv}</Small>
</Card>
))}
<Spacer size={spacing.sm} />
</>
)}
{cat === null &&
!submittedSearch &&
!holiday &&
query.data.mealBoxSuggestions.length > 0 && (
<>
<Heading>{t("wte.mealBoxFirst")}</Heading>
{query.data.mealBoxSuggestions.map((box) => (
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {box.titleSv}</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Small>{box.whySv}</Small>
</Card>
))}
<Spacer size={spacing.sm} />
</>
)}
{allRecs.length === 0 && (
<EmptyState
text={submittedSearch ? "Inga recept matchar din sökning." : t("wte.empty")}
text={
submittedSearch
? "Inga recept matchar din sökning."
: holiday
? "Inga recept för den här högtiden än."
: t("wte.empty")
}
/>
)}
@@ -221,7 +248,7 @@ export default function WhatToEatScreen() {
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{rec.titleSv}</Heading>
{page === 0 && index === 0 && !submittedSearch && (
{page === 0 && index === 0 && !submittedSearch && !holiday && (
<Tag label="Toppval" tone="accent" />
)}
</Row>
+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. */