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);
});
});
+70
View File
@@ -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` 0100.
- 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")?
+8
View File
@@ -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(),
});