From 9197d93a9216578915be16abe53d76903f2c918c Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Sat, 8 Aug 2026 05:53:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20naturlig-spr=C3=A5klig=20fri?= =?UTF-8?q?texts=C3=B6kning=20med=20FoodTwin-fakta,=20trust-hedge=20och=20?= =?UTF-8?q?i18n=20=C3=9712?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nytt endpoint GET /v1/inventory/natural-search. - Robust matchning med unaccent + pg_trgm similarity mot display_name, brand och kanoniska ingrediensnamn (sv/en). - Svar byggs deterministiskt från faktisk data: lagringsplats + sublocation om registrerad, trust-state-hedge från befintlig trust-motor. - Server-sidan i18n-katalog för 12 språk. - Tester för svensk träff och noll-träff på flera språk. - Design-dokument docs/30-sökrobusthet-matvaror.md. --- apps/api/src/lib/inventorySearchResponse.ts | 235 ++++++++++++++++++ apps/api/src/routes/inventory.ts | 112 +++++++++ .../api/test/inventory-natural-search.test.ts | 136 ++++++++++ docs/30-sökrobusthet-matvaror.md | 70 ++++++ packages/validation/src/inventory.ts | 8 + 5 files changed, 561 insertions(+) create mode 100644 apps/api/src/lib/inventorySearchResponse.ts create mode 100644 apps/api/test/inventory-natural-search.test.ts create mode 100644 docs/30-sökrobusthet-matvaror.md diff --git a/apps/api/src/lib/inventorySearchResponse.ts b/apps/api/src/lib/inventorySearchResponse.ts new file mode 100644 index 0000000..8ada9a5 --- /dev/null +++ b/apps/api/src/lib/inventorySearchResponse.ts @@ -0,0 +1,235 @@ +import type { TrustState } from "@app/shared-types"; + +export type SearchResultItem = { + displayName: string; + quantity: number; + unit: string; + locationName: string; + locationType: string; + sublocation: string | null; + trustState: TrustState; + trustScore: number; +}; + +function lang(languageTag: string): string { + return (languageTag.split("-")[0] ?? "sv").toLowerCase(); +} + +const TRUST_HEDGE: Record> = { + trusted: { + sv: "Jag är ganska säker på att den finns kvar.", + en: "I'm fairly sure it's still there.", + da: "Jeg er ret sikker på, at den stadig er der.", + de: "Ich bin mir ziemlich sicher, dass es noch da ist.", + es: "Estoy bastante seguro de que sigue ahí.", + fi: "Olen melko varma, että se on edelleen siellä.", + fr: "Je suis assez sûr qu'il est toujours là.", + it: "Sono abbastanza sicuro che sia ancora lì.", + nb: "Jeg er ganske sikker på at den fremdeles er der.", + nl: "Ik ben redelijk zeker dat het er nog steeds is.", + pl: "Jestem dość pewien, że wciąż tam jest.", + pt: "Estou bastante certo de que ainda está lá.", + }, + decaying: { + sv: "Jag är lite osäker – det var ett tag sedan den uppdaterades.", + en: "I'm a bit unsure – it hasn't been updated in a while.", + da: "Jeg er lidt usikker – det er et stykke tid siden, den blev opdateret.", + de: "Ich bin mir etwas unsicher – es wurde schon eine Weile nicht mehr aktualisiert.", + es: "No estoy muy seguro: hace tiempo que no se actualiza.", + fi: "Olen hieman epävarma – sitä ei ole päivitetty vähään aikaan.", + fr: "Je ne suis pas très sûr – cela fait un moment qu'il n'a pas été mis à jour.", + it: "Non sono molto sicuro: non viene aggiornato da un po'.", + nb: "Jeg er litt usikker – det er en stund siden den ble oppdatert.", + nl: "Ik ben een beetje onzeker – het is al een tijdje niet meer bijgewerkt.", + pl: "Nie jestem pewien – od dawna nie było aktualizacji.", + pt: "Não tenho muita certeza: já faz algum tempo desde a última atualização.", + }, + stale: { + sv: "Jag är osäker – den här posten är gammal och kanske inte stämmer längre.", + en: "I'm unsure – this entry is old and may no longer be accurate.", + da: "Jeg er usikker – denne post er gammel og passer måske ikke længere.", + de: "Ich bin unsicher – dieser Eintrag ist alt und möglicherweise nicht mehr aktuell.", + es: "No estoy seguro: esta entrada es antigua y quizás ya no sea correcta.", + fi: "Olen epävarma – tämä merkintä on vanha eikä välttämättä pidä enää paikkaansa.", + fr: "Je ne suis pas sûr – cette entrée est ancienne et pourrait ne plus être exacte.", + it: "Non ne sono sicuro: questa voce è vecchia e potrebbe non essere più accurata.", + nb: "Jeg er usikker – denne posten er gammel og stemmer kanskje ikke lenger.", + nl: "Ik ben onzeker – dit item is oud en is mogelijk niet meer accuraat.", + pl: "Nie jestem pewien – ten wpis jest stary i może nie być już aktualny.", + pt: "Não tenho a certeza: esta entrada é antiga e pode já não estar correta.", + }, + unverified: { + sv: "Jag är osäker – posten är ännu inte verifierad.", + en: "I'm unsure – this entry hasn't been verified yet.", + da: "Jeg er usikker – posten er endnu ikke bekræftet.", + de: "Ich bin unsicher – dieser Eintrag wurde noch nicht verifiziert.", + es: "No estoy seguro: esta entrada aún no ha sido verificada.", + fi: "Olen epävarma – tätä merkintää ei ole vielä vahvistettu.", + fr: "Je ne suis pas sûr – cette entrée n'a pas encore été vérifiée.", + it: "Non ne sono sicuro: questa voce non è stata ancora verificata.", + nb: "Jeg er usikker – posten er ikke verifisert ennå.", + nl: "Ik ben onzeker – dit item is nog niet geverifieerd.", + pl: "Nie jestem pewien – ten wpis nie został jeszcze zweryfikowany.", + pt: "Não tenho a certeza: esta entrada ainda não foi verificada.", + }, +}; + +const LOCATION_TEMPLATES: Record> = { + sv: { + withSublocation: "i {{location}} ({{sublocation}})", + withoutSublocation: "i {{location}}", + }, + en: { + withSublocation: "in the {{location}} ({{sublocation}})", + withoutSublocation: "in the {{location}}", + }, + da: { + withSublocation: "i {{location}} ({{sublocation}})", + withoutSublocation: "i {{location}}", + }, + de: { + withSublocation: "im {{location}} ({{sublocation}})", + withoutSublocation: "im {{location}}", + }, + es: { + withSublocation: "en el {{location}} ({{sublocation}})", + withoutSublocation: "en el {{location}}", + }, + fi: { + withSublocation: "{{location}}:ssa ({{sublocation}})", + withoutSublocation: "{{location}}:ssa", + }, + fr: { + withSublocation: "dans le {{location}} ({{sublocation}})", + withoutSublocation: "dans le {{location}}", + }, + it: { + withSublocation: "nel {{location}} ({{sublocation}})", + withoutSublocation: "nel {{location}}", + }, + nb: { + withSublocation: "i {{location}} ({{sublocation}})", + withoutSublocation: "i {{location}}", + }, + nl: { + withSublocation: "in de {{location}} ({{sublocation}})", + withoutSublocation: "in de {{location}}", + }, + pl: { + withSublocation: "w {{location}} ({{sublocation}})", + withoutSublocation: "w {{location}}", + }, + pt: { + withSublocation: "no {{location}} ({{sublocation}})", + withoutSublocation: "no {{location}}", + }, +}; + +const ZERO_RESULTS: Record = { + sv: "Jag hittade ingen träff i lagret just nu. Vill du lägga till den?", + en: "I couldn't find a match in the inventory right now. Would you like to add it?", + da: "Jeg fandt intet match i lageret lige nu. Vil du tilføje det?", + de: "Ich habe gerade keine Übereinstimmung im Lager gefunden. Möchtest du sie hinzufügen?", + es: "No he encontrado ninguna coincidencia en la despensa ahora mismo. ¿Quieres añadirla?", + fi: "En löytänyt osumaa varastosta juuri nyt. Haluatko lisätä sen?", + fr: "Je n'ai trouvé aucune correspondance dans l'inventaire pour l'instant. Voulez-vous l'ajouter ?", + it: "Non ho trovato corrispondenze in dispensa in questo momento. Vuoi aggiungerla?", + nb: "Jeg fant ingen treff i lageret akkurat nå. Vil du legge det til?", + nl: "Ik heb momenteel geen overeenkomst in de voorraad gevonden. Wil je het toevoegen?", + pl: "Nie znalazłem teraz dopasowania w inwentarzu. Czy chcesz to dodać?", + pt: "Não encontrei nenhuma correspondência na despensa de momento. Queres adicioná-la?", +}; + +const SINGLE_RESULT_PREFIX: Record = { + sv: "Jag hittade **{{name}}** {{location}}.", + en: "I found **{{name}}** {{location}}.", + da: "Jeg fandt **{{name}}** {{location}}.", + de: "Ich habe **{{name}}** {{location}} gefunden.", + es: "Encontré **{{name}}** {{location}}.", + fi: "Löysin **{{name}}** {{location}}.", + fr: "J'ai trouvé **{{name}}** {{location}}.", + it: "Ho trovato **{{name}}** {{location}}.", + nb: "Jeg fant **{{name}}** {{location}}.", + nl: "Ik heb **{{name}}** {{location}} gevonden.", + pl: "Znalazłem **{{name}}** {{location}}.", + pt: "Encontrei **{{name}}** {{location}}.", +}; + +const MULTI_RESULT_PREFIX: Record = { + sv: "Jag hittade {{count}} saker som kan matcha:", + en: "I found {{count}} things that could match:", + da: "Jeg fandt {{count}} ting, der kan matche:", + de: "Ich habe {{count}} Dinge gefunden, die passen könnten:", + es: "Encontré {{count}} cosas que podrían coincidir:", + fi: "Löysin {{count}} asiaa, jotka saattavat sopia:", + fr: "J'ai trouvé {{count}} choses qui pourraient correspondre :", + it: "Ho trovato {{count}} cose che potrebbero corrispondere:", + nb: "Jeg fant {{count}} ting som kan matche:", + nl: "Ik heb {{count}} dingen gevonden die kunnen overeenkomen:", + pl: "Znalazłem {{count}} rzeczy, które mogą pasować:", + pt: "Encontrei {{count}} coisas que podem corresponder:", +}; + +const MULTI_RESULT_SUFFIX: Record = { + sv: "Titta på listan nedan så ser du hur säker jag är på varje.", + en: "Check the list below to see how sure I am about each one.", + da: "Se listen nedenfor for at se, hvor sikker jeg er på hver enkelt.", + de: "Sieh dir die Liste unten an, um zu sehen, wie sicher ich bei jedem Einzelnen bin.", + es: "Mira la lista de abajo para ver qué tan seguro estoy de cada una.", + fi: "Katso alla olevaa luetteloa nähdäksesi, kuinka varma olen kustakin.", + fr: "Consulte la liste ci-dessous pour voir à quel point je suis sûr de chacun.", + it: "Guarda l'elenco qui sotto per vedere quanto sono sicuro di ciascuno.", + nb: "Se listen nedenfor for å se hvor sikker jeg er på hver enkelt.", + nl: "Bekijk de onderstaande lijst om te zien hoe zeker ik van elk item ben.", + pl: "Sprawdź listę poniżej, aby zobaczyć, jak pewny jestem każdej pozycji.", + pt: "Vê a lista abaixo para veres o quão certo estou de cada uma.", +}; + +function resolve(catalog: Record, languageTag: string): string { + return catalog[lang(languageTag)] ?? catalog["en"] ?? catalog["sv"]!; +} + +function formatLocation(item: SearchResultItem, languageTag: string): string { + const templates = LOCATION_TEMPLATES[lang(languageTag)] ?? LOCATION_TEMPLATES["sv"]!; + const tpl = item.sublocation ? templates.withSublocation : templates.withoutSublocation; + if (!tpl) return ""; + return tpl + .replace("{{location}}", item.locationName) + .replace("{{sublocation}}", item.sublocation ?? ""); +} + +function formatSingle(item: SearchResultItem, languageTag: string): string { + const prefix = resolve(SINGLE_RESULT_PREFIX, languageTag) + .replace("{{name}}", item.displayName) + .replace("{{location}}", formatLocation(item, languageTag)); + const hedge = resolve(TRUST_HEDGE[item.trustState], languageTag); + return `${prefix} ${hedge}`; +} + +function formatList(items: SearchResultItem[], languageTag: string): string { + const l = lang(languageTag); + const parts = items.map((item) => { + const loc = formatLocation(item, languageTag); + return `• ${item.displayName} ${loc}`; + }); + const prefix = resolve(MULTI_RESULT_PREFIX, languageTag).replace("{{count}}", String(items.length)); + const suffix = resolve(MULTI_RESULT_SUFFIX, languageTag); + return `${prefix}\n${parts.join("\n")}\n${suffix}`; +} + +/** + * Bygg ett naturligt-språkligt svar för fritextsökning i hushållslagret. + * Ingen data fabriceras – allt som visas kommer från `items`. + */ +export function buildNaturalSearchResponse( + items: SearchResultItem[], + languageTag: string, +): string { + if (items.length === 0) { + return resolve(ZERO_RESULTS, languageTag); + } + if (items.length === 1) { + return formatSingle(items[0]!, languageTag); + } + return formatList(items, languageTag); +} diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts index e3338ba..7348188 100644 --- a/apps/api/src/routes/inventory.ts +++ b/apps/api/src/routes/inventory.ts @@ -4,6 +4,7 @@ import { schema } from "@app/database"; import { createInventoryItemInputSchema, idParamSchema, + inventoryNaturalSearchQuerySchema, inventoryQuerySchema, inventoryTransactionInputSchema, updateInventoryItemInputSchema, @@ -22,6 +23,8 @@ import { requireActiveHousehold, requireMembership, } from "../lib/helpers.js"; +import { buildNaturalSearchResponse } from "../lib/inventorySearchResponse.js"; +import { userLanguageTag } from "../lib/contentLanguage.js"; /** * Food Twin – lagret (spec §8). Transaktionsbaserat: varje förändring skrivs @@ -139,6 +142,115 @@ export async function inventoryRoutes(app: FastifyInstance) { }; }); + /** + * Naturlig-språklig fritextsökning i lagret (FoodTwin + trust-hedge). + * Returnerar både strukturerade träffar och ett mänskligt svar. + */ + app.get("/v1/inventory/natural-search", auth, async (req) => { + const query = parse(inventoryNaturalSearchQuerySchema, req.query); + const householdId = await requireActiveHousehold(app.db, req.userId); + const decayProfile = await getActiveDecayProfile(app.db); + const languageTag = query.languageTag ?? (await userLanguageTag(app.db, req.userId)); + + const q = query.q; + const pattern = `%${q}%`; + const similarityThreshold = 0.15; + + const rows = await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + locationName: schema.storageLocations.name, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + or( + sql`unaccent(${schema.inventoryItems.displayName}) ILIKE unaccent(${pattern})`, + sql`similarity(${schema.inventoryItems.displayName}, ${q}) > ${similarityThreshold}`, + sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`, + sql`unaccent(${schema.canonicalIngredients.nameEn}) ILIKE unaccent(${pattern})`, + sql`similarity(${schema.canonicalIngredients.nameSv}, ${q}) > ${similarityThreshold}`, + sql`similarity(${schema.canonicalIngredients.nameEn}, ${q}) > ${similarityThreshold}`, + sql`unaccent(${schema.inventoryItems.brand}) ILIKE unaccent(${pattern})`, + )!, + ), + ) + .orderBy( + desc( + sql`GREATEST( + similarity(${schema.inventoryItems.displayName}, ${q}), + similarity(${schema.canonicalIngredients.nameSv}, ${q}), + similarity(${schema.canonicalIngredients.nameEn}, ${q}) + )`, + ), + ) + .limit(query.limit); + + const resultItems = rows.map((r) => { + const expiry = classifyExpiry({ + bestBeforeDate: r.item.bestBeforeDate, + useByDate: r.item.useByDate, + openedAt: r.item.openedAt, + frozenAt: r.item.frozenAt, + thawedAt: r.item.thawedAt, + purchasedAt: r.item.purchasedAt, + storageLocationType: r.locationType, + shelfLifeGuidance: r.shelfLife, + }); + const trust = computeTrust( + { + confidence: r.item.confidence, + verifiedByUser: r.item.verifiedByUser, + lastVerifiedAt: r.item.lastVerifiedAt, + quantity: r.item.quantity, + updatedAt: r.item.updatedAt, + }, + new Date(), + decayProfile, + ); + return { + ...r.item, + locationName: r.locationName, + locationType: r.locationType, + expiry, + trustState: trust.state, + trustScore: trust.score, + }; + }); + + const naturalItems = resultItems.map((item) => ({ + displayName: item.displayName, + quantity: item.quantity, + unit: item.unit, + locationName: item.locationName, + locationType: item.locationType, + sublocation: item.sublocation, + trustState: item.trustState, + trustScore: item.trustScore, + })); + + const response = buildNaturalSearchResponse(naturalItems, languageTag); + + return { + query: q, + response, + items: resultItems, + }; + }); + /** Varor som bör användas snart – driver "använd först" (spec §4.4, §40). */ app.get("/v1/inventory/expiring", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); diff --git a/apps/api/test/inventory-natural-search.test.ts b/apps/api/test/inventory-natural-search.test.ts new file mode 100644 index 0000000..b4c6695 --- /dev/null +++ b/apps/api/test/inventory-natural-search.test.ts @@ -0,0 +1,136 @@ +import "./setup-env.js"; +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { eq, inArray } from "drizzle-orm"; +import { buildServer } from "../src/server.js"; +import { loadConfig } from "../src/config.js"; +import { createDatabase, closeDatabase, schema } from "@app/database"; + +describe("GET /v1/inventory/natural-search", () => { + const testDb = createDatabase(process.env.TEST_DATABASE_URL!); + const config = loadConfig(); + let app: Awaited>; + + beforeAll(async () => { + app = await buildServer(config); + await app.ready(); + }); + + afterAll(async () => { + await closeDatabase(); + await app.close(); + }); + + async function registerUser(email: string) { + const res = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email, password: "Password123!", displayName: "Search Test" }, + }); + const body = JSON.parse(res.body) as { accessToken: string }; + const token = body.accessToken; + const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; + + await app.inject({ + method: "POST", + url: "/v1/onboarding/quick-start", + headers: { authorization: `Bearer ${token}` }, + payload: { goals: ["less_waste"], precisionMode: "simple" }, + }); + + return { token, userId }; + } + + async function cleanupUser(email: string) { + const existing = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, email)); + for (const u of existing) { + const memberships = await testDb.db + .select({ householdId: schema.householdMembers.householdId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.userId, u.id)); + const householdIds = memberships.map((m) => m.householdId); + if (householdIds.length > 0) { + await testDb.db.delete(schema.inventoryItems).where(inArray(schema.inventoryItems.householdId, householdIds)); + await testDb.db.delete(schema.storageLocations).where(inArray(schema.storageLocations.householdId, householdIds)); + await testDb.db.delete(schema.householdMembers).where(inArray(schema.householdMembers.householdId, householdIds)); + await testDb.db.delete(schema.households).where(inArray(schema.households.id, householdIds)); + } + await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); + } + } + + it("returnerar ett mänskligt svar med plats och trust-hedge", async () => { + const email = "inventory-search-1@example.invalid"; + await cleanupUser(email); + const { token, userId } = await registerUser(email); + + const [household] = await testDb.db + .select({ id: schema.households.id }) + .from(schema.households) + .innerJoin(schema.householdMembers, eq(schema.households.id, schema.householdMembers.householdId)) + .where(eq(schema.householdMembers.userId, userId)) + .limit(1); + + const [fridge] = await testDb.db + .insert(schema.storageLocations) + .values({ + householdId: household!.id, + type: "fridge", + name: "Kylen", + sublocations: ["mellanmålshyllan"], + }) + .returning(); + + await testDb.db.insert(schema.inventoryItems).values({ + householdId: household!.id, + displayName: "Mjölk", + quantity: 1, + unit: "LITER", + storageLocationId: fridge!.id, + sublocation: "mellanmålshyllan", + source: "manual_search", + confidence: 1, + verifiedByUser: true, + lastVerifiedAt: new Date(), + trustState: "trusted", + }); + + const res = await app.inject({ + method: "GET", + url: "/v1/inventory/natural-search?q=mj%C3%B6lk&languageTag=sv-SE", + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body) as { response: string; items: unknown[] }; + expect(body.response).toContain("Mjölk"); + expect(body.response).toContain("Kylen"); + expect(body.response).toContain("mellanmålshyllan"); + expect(body.response).toContain("säker"); + expect(body.items).toHaveLength(1); + + await cleanupUser(email); + }); + + it("hanterar noll träffar på flera språk", async () => { + const email = "inventory-search-2@example.invalid"; + await cleanupUser(email); + const { token } = await registerUser(email); + + for (const languageTag of ["sv-SE", "en-GB", "de-DE"]) { + const res = await app.inject({ + method: "GET", + url: `/v1/inventory/natural-search?q=unobtainium&languageTag=${encodeURIComponent(languageTag)}`, + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { response: string; items: unknown[] }; + expect(body.items).toHaveLength(0); + expect(body.response.length).toBeGreaterThan(10); + } + + await cleanupUser(email); + }); +}); diff --git a/docs/30-sökrobusthet-matvaror.md b/docs/30-sökrobusthet-matvaror.md new file mode 100644 index 0000000..8248941 --- /dev/null +++ b/docs/30-sökrobusthet-matvaror.md @@ -0,0 +1,70 @@ +# Sökrobusthet för matvaror i hushållslagret + +> Målbild: användaren kan ställa en fritextfråga om vad som finns hemma och få ett svar som känns mänskligt, visar exakt lagring (FoodTwin) och ärligt berättar hur säker appen är – utan att hitta på något. + +## Befintliga motorer + +### Sökmotor: `/v1/inventory` +- Enkel `ILIKE` mot `inventory_items.display_name` och `brand`. +- Inga synonymer, ingen fonetisk matchning, ingen felstavningstolerans. +- Filtrering på `storage_location_id`, `expiry_status`, paginering. + +### Förbättrad sökmotor: `/v1/inventory/natural-search` +- Kombinerar `unaccent(...) ILIKE unaccent(...)` med PostgreSQL `pg_trgm`-similarity. +- Söker även mot kanoniska ingrediensnamn (`name_sv`, `name_en`). +- Sorterar på `GREATEST(similarity(...))`. +- Returnerar både strukturerade träffar och ett naturligt-språkligt svar. + +### Trust-motor (`@app/inventory-engine`) +- `computeTrust(...)` ger `trustState` (`trusted`/`decaying`/`stale`/`unverified`) och `trustScore` 0–100. +- Baserat på `confidence`, `verifiedByUser`, ålder och förruttnelseprofil. +- Används redan i `/v1/inventory` och vid skanning/konsumtion. + +### FoodTwin-data +- `storage_locations`: namn, typ, sublocations. +- `inventory_items`: `storage_location_id`, `sublocation`, kvantitet, enhet, `trust_state`. +- `canonical_ingredients`: kanoniskt namn, synonymer, hållbarhetsriktlinjer. + +## Svarsformat + +`GET /v1/inventory/natural-search?q=mjölk&languageTag=sv-SE` + +```json +{ + "query": "mjölk", + "response": "Jag hittade **Mjölk** i Kylen (mellanmålshyllan). Jag är ganska säker på att den finns kvar.", + "items": [ + { + "id": "...", + "displayName": "Mjölk", + "quantity": 1, + "unit": "LITER", + "locationName": "Kylen", + "locationType": "fridge", + "sublocation": "mellanmålshyllan", + "trustState": "trusted", + "trustScore": 95 + } + ] +} +``` + +## i18n + +Server-sidan använder en minimal katalog i `apps/api/src/lib/inventorySearchResponse.ts`. +Stödja språk (12): sv, en, da, de, es, fi, fr, it, nb, nl, pl, pt. +Vid okänt språk faller vi tillbaka till engelska och sedan svenska. + +## Hårda regler + +1. **Inga påhittade fakta.** Om sublocation saknas visas inte "på hylla X". +2. **Trust-hedge måste matcha `trustState`.** `stale` ger en osäker formulering; `trusted` ger en trygg. +3. **Noll träffar** ska erkänna det och erbjuda att lägga till. +4. **Flera träffar** ska lista dem med lagring och peka på listan för trust-score. + +## Nästa steg / öppna frågor + +- Ska vi också söka i `canonical_ingredients.aliases` (array)? Kräver en `unnest`/`CROSS JOIN LATERAL` eller en trigram-index på en materialiserad vy. +- Ska vi lägga till fonetisk matchning (`fuzzystrmatch`) för vanliga felstavningar? +- Ska svaret också inkludera hållbarhetsstatus för träffarna? +- Behövs en separat vector/embedding-sökning för semantiska matchningar (t.ex. "mjölkprodukter")? diff --git a/packages/validation/src/inventory.ts b/packages/validation/src/inventory.ts index d4fc7c0..f01c930 100644 --- a/packages/validation/src/inventory.ts +++ b/packages/validation/src/inventory.ts @@ -44,6 +44,14 @@ export const inventoryQuerySchema = z.object({ }); export type InventoryQuery = z.infer; +/** Naturligt-språklig fritextsökning i hushållslagret (spec §8). */ +export const inventoryNaturalSearchQuerySchema = z.object({ + q: z.string().min(1).max(80).trim(), + languageTag: z.string().min(2).max(35).default("sv-SE"), + limit: z.coerce.number().int().min(1).max(20).default(5), +}); +export type InventoryNaturalSearchQuery = z.infer; + export const reconciliationStartInputSchema = z.object({ maxItems: z.coerce.number().int().min(1).max(50).optional(), });