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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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