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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
/** 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({
|
||||
maxItems: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user