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
+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);