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:
@@ -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