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);
|
||||
|
||||
Reference in New Issue
Block a user