feat(inventory): naturlig-språklig fritextsökning med FoodTwin-fakta, trust-hedge och i18n ×12
- 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.
This commit is contained in:
@@ -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<TrustState, Record<string, string>> = {
|
||||||
|
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<string, Record<string, string>> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string>, 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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { schema } from "@app/database";
|
|||||||
import {
|
import {
|
||||||
createInventoryItemInputSchema,
|
createInventoryItemInputSchema,
|
||||||
idParamSchema,
|
idParamSchema,
|
||||||
|
inventoryNaturalSearchQuerySchema,
|
||||||
inventoryQuerySchema,
|
inventoryQuerySchema,
|
||||||
inventoryTransactionInputSchema,
|
inventoryTransactionInputSchema,
|
||||||
updateInventoryItemInputSchema,
|
updateInventoryItemInputSchema,
|
||||||
@@ -22,6 +23,8 @@ import {
|
|||||||
requireActiveHousehold,
|
requireActiveHousehold,
|
||||||
requireMembership,
|
requireMembership,
|
||||||
} from "../lib/helpers.js";
|
} 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
|
* 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). */
|
/** Varor som bör användas snart – driver "använd först" (spec §4.4, §40). */
|
||||||
app.get("/v1/inventory/expiring", auth, async (req) => {
|
app.get("/v1/inventory/expiring", auth, async (req) => {
|
||||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||||
|
|||||||
@@ -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<ReturnType<typeof buildServer>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")?
|
||||||
@@ -44,6 +44,14 @@ export const inventoryQuerySchema = z.object({
|
|||||||
});
|
});
|
||||||
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
|
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
|
||||||
|
|
||||||
|
/** 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<typeof inventoryNaturalSearchQuerySchema>;
|
||||||
|
|
||||||
export const reconciliationStartInputSchema = z.object({
|
export const reconciliationStartInputSchema = z.object({
|
||||||
maxItems: z.coerce.number().int().min(1).max(50).optional(),
|
maxItems: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user