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:
Sven (AAMOS AI)
2026-08-08 05:53:06 +07:00
parent ddd6b736ba
commit 9197d93a92
5 changed files with 561 additions and 0 deletions
+235
View File
@@ -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);
}
+112
View File
@@ -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);
@@ -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);
});
});