groceries: relevance-ranked search (plain/basic products first)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

- search.ts: score on the core name (brand/size stripped): exact or query+base suffix
  (kipdij+filet), head-final compounds, prefix, inside; penalties per extra word and per
  flavour/processing word (search-words.ts, editable); bonuses for store brands, the
  query's dominant category, store count, favourites; trigram tiebreak
- groceries.synonyms table (seeded: kipdij/kippendij, pasta → spaghetti, …) expands queries
- list rows show all pack sizes of a group; group picker uses the same ranking
- scripts/groceries-search-eval.ts prints top results + latency; preflight now 8 tables
This commit is contained in:
2026-10-25 20:20:34 +00:00
parent 5028524537
commit 60370d4572
14 changed files with 31221 additions and 16 deletions

View File

@@ -4,6 +4,8 @@ import {
cheapest,
formatAmount,
formatUnitPrice,
packLabel,
packSizes,
parseEuros,
sortByPrice,
toOffers,
@@ -117,3 +119,21 @@ describe('bestPerStore', () => {
expect(bestPerStore(toOffers([item(1, 1, 100, 100)], []), 'pack').spread).toBeNull();
});
});
describe('packSizes', () => {
it('lists distinct pack sizes smallest first with friendly units', () => {
expect(
packSizes([
{ unitAmount: 0.8, unit: 'kg' },
{ unitAmount: 0.22, unit: 'kg' },
{ unitAmount: 0.45, unit: 'kg' },
{ unitAmount: 0.22, unit: 'kg' },
{ unitAmount: 1.5, unit: 'kg', available: false },
{ unitAmount: null, unit: null }
])
).toEqual(['220 g', '450 g', '800 g']);
expect(packLabel(1.5, 'l')).toBe('1,5 L');
expect(packLabel(0.33, 'l')).toBe('330 ml');
expect(packLabel(6, 'piece')).toBe('6 st');
});
});

View File

@@ -178,3 +178,25 @@ export function bestPerStore(offers: Offer[], mode: 'unit' | 'pack') {
: null;
return { best: stores[0] ?? null, stores, spread };
}
/** Pack size label: 0.5 kg → "500 g", 1.5 l → "1,5 L", 0.33 l → "330 ml", 6 piece → "6 st". */
export function packLabel(amount: number | null | undefined, unit: string | null | undefined) {
if (amount == null || !(amount > 0) || !unit) return null;
const nl = (n: number) => n.toLocaleString('nl-NL', { maximumFractionDigits: 2 });
if (unit === 'kg' && amount < 1) return `${nl(Math.round(amount * 1000))} g`;
if (unit === 'l' && amount < 1) return `${nl(Math.round(amount * 1000))} ml`;
return `${nl(amount)} ${UNIT_LABEL[unit] ?? unit}`;
}
/** Distinct pack sizes of a group's products, smallest first ("220 g", "450 g", "800 g"). */
export function packSizes(
items: { unitAmount: number | null; unit: string | null; available?: boolean }[]
): string[] {
const seen = new Map<string, number>();
for (const i of items) {
if (i.available === false) continue;
const label = packLabel(i.unitAmount, i.unit);
if (label && !seen.has(label)) seen.set(label, i.unitAmount!);
}
return [...seen].sort((a, b) => a[1] - b[1]).map(([l]) => l);
}

View File

@@ -256,8 +256,30 @@ export const groceryImportRuns = groceriesSchema.table(
(t) => [index('import_runs_store_idx').on(t.storeId, t.startedAt)]
);
/**
* Search synonyms (search.ts): a query word `term` also finds products with `synonym`.
* One-directional (pasta → spaghetti); spelling variants are stored both ways. Folded,
* lower-case words; edits apply within 5 minutes (cached).
*/
export const grocerySynonyms = groceriesSchema.table(
'synonyms',
{
id: id(),
term: text('term').notNull(),
synonym: text('synonym').notNull(),
...timestamps
},
(t) => [
uniqueIndex('synonyms_term_synonym_key').on(t.term, t.synonym),
check('synonyms_term_check', sql`${t.term} ~ '^[a-z0-9][a-z0-9 -]{0,40}$'`),
check('synonyms_synonym_check', sql`${t.synonym} ~ '^[a-z0-9][a-z0-9 -]{0,40}$'`),
check('synonyms_not_self', sql`${t.term} <> ${t.synonym}`)
]
);
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryGroup = typeof groceryGroups.$inferSelect;
export type GroceryItem = typeof groceryItems.$inferSelect;
export type GroceryCategory = typeof groceryCategories.$inferSelect;
export type GroceryManualPrice = typeof groceryManualPrices.$inferSelect;
export type GrocerySynonym = typeof grocerySynonyms.$inferSelect;

View File

@@ -284,7 +284,7 @@ const SIZE_IN_NAME = new RegExp(
'gi'
);
const stem = (t: string) =>
export const stem = (t: string) =>
t.length > 5 && t.endsWith('en')
? t.slice(0, -2)
: t.length > 4 && t.endsWith('s')

View File

@@ -0,0 +1,215 @@
// Editable word lists for grocery search ranking (see search.ts). Lower-case, accents allowed
// (they are folded). Synonyms live in the groceries.synonyms table so they can change without
// a deploy; these lists change with the code.
/**
* Base forms that still mean "the plain product" when glued to the query or next to it:
* kipdij + filet, kipdijfilet + blokjes, gehakt, … "query+suffix" counts as an exact match;
* the value is a small penalty so the plainest cut comes first.
*/
export const BASE_SUFFIXES: Record<string, number> = {
// value = small penalty: kipdijfilet is the plainest, blokjes/reepjes are a cut further.
filet: 0,
filets: 0,
vlees: 0,
naturel: 0,
puur: 0,
plakken: 1,
plakjes: 1,
gehakt: 2,
stuk: 2,
stukken: 2,
stukjes: 4,
schijven: 3,
schijfjes: 3,
lapjes: 3,
lapje: 3,
blokjes: 5,
blokje: 5,
reepjes: 5,
reepje: 5
};
/**
* Words that make a product "more than the basic thing", with a penalty weight (1 = one strong
* flavour/processing word; 0.1 = a harmless qualifier like halfvolle or scharrel). Words not in
* this list that aren't part of the query cost a fixed extra-word penalty instead.
*/
export const MODIFIERS: Record<string, number> = {
// flavoured, marinated, processed
gekruid: 1,
gekruide: 1,
gemarineerd: 1,
gemarineerde: 1,
madras: 1,
shoarma: 1,
doner: 1,
kebab: 1,
gyros: 1,
spies: 1,
spiesjes: 1,
spiesje: 1,
sate: 1,
satay: 1,
gegrild: 1,
gegrilde: 1,
grill: 1,
tandoori: 1,
tikka: 1,
honing: 1,
pittig: 1,
pittige: 1,
piri: 1,
cajun: 1,
teriyaki: 1,
bbq: 1,
barbecue: 1,
knoflook: 0.8,
paprika: 0.8,
kerrie: 0.8,
curry: 0.8,
tuinkruiden: 0.8,
pesto: 0.8,
krokant: 1,
krokante: 1,
gepaneerd: 1,
gepaneerde: 1,
gerookt: 0.8,
gerookte: 0.8,
gebakken: 0.8,
gefrituurd: 1,
burger: 1,
burgers: 1,
worst: 1,
worstjes: 1,
nuggets: 1,
schnitzel: 1,
cordon: 1,
rollade: 1,
salade: 1,
saus: 1,
spread: 1,
snack: 1,
snacks: 1,
chips: 1,
soep: 1,
maaltijd: 1,
chocolade: 1,
choco: 1,
aardbei: 0.8,
vanille: 0.8,
banaan: 0.8,
drink: 0.8,
drank: 0.8,
dessert: 1,
toetje: 1,
ijs: 1,
wafels: 1,
koek: 1,
koekjes: 1,
reep: 1,
repen: 1,
carbonara: 1,
bolognese: 1,
pomodoro: 1,
kaassaus: 1,
kip: 0.5,
mix: 0.6,
smaak: 0.6,
rooksmaak: 1,
rook: 0.8,
style: 0.6,
// pets and non-food that share food words (kattensnoepjes "kaas", …)
kat: 1,
katten: 1,
hond: 1,
honden: 1,
snoepjes: 1,
// harmless qualifiers: still the basic product
halfvolle: 0.1,
halfvol: 0.1,
volle: 0.1,
vol: 0.1,
magere: 0.1,
mager: 0.1,
scharrel: 0.1,
vrije: 0.1,
uitloop: 0.1,
biologisch: 0.2,
biologische: 0.2,
bio: 0.2,
verse: 0.1,
vers: 0.1,
jong: 0.15,
jonge: 0.15,
belegen: 0.15,
oud: 0.2,
oude: 0.2,
extra: 0.2,
goudse: 0.15,
gouda: 0.15,
basmati: 0.2,
pandan: 0.2,
zilvervlies: 0.2,
witte: 0.15,
wit: 0.15,
bruine: 0.2,
volkoren: 0.2,
zachte: 0.15,
stevige: 0.15,
naturel: 0,
ongezouten: 0.1,
halal: 0.2,
groot: 0.1,
grote: 0.1,
klein: 0.1,
kleine: 0.1,
lang: 0.1,
houdbaar: 0.1,
langlekker: 0.2,
plakken: 0.1,
geraspt: 0.2,
geraspte: 0.2,
durum: 0.1,
tarwe: 0.1
};
/**
* Private-label / store brands (folded brand label, matched exactly or as a prefix). "Huismerk…"
* labels come from the grouping (AH/Jumbo/PLUS house brands share a group), see normalize.ts.
*/
export const STORE_BRANDS = [
'huismerk',
'ah',
'albert heijn',
'jumbo',
"jumbo's",
'plus',
'1 de beste',
"g'woon",
'vomar',
'hoogvliet',
'dirk',
'aldi',
'milsani',
'lidl',
'boni',
'spar',
'coop',
'deen',
'poiesz',
'dekamarkt',
'jan linders',
'nettorama',
'merkloos'
];
/** Brand labels that are a store's own line but not its plain one (smaller bonus). */
export const STORE_BRAND_PREMIUM = [
'huismerk premium',
'huismerk kids',
'huismerk plantaardig',
'ah excellent',
'jumbo excellent',
'plus excellent'
];

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import {
brandBonus,
expand,
queryWords,
rank,
synonymMap,
textScore,
wordPenalty,
type Candidate
} from './search';
let nextId = 1;
const c = (
name: string,
brand: string | null,
categoryId: number | null,
storeCount = 1,
extra: Partial<Candidate> = {}
): Candidate => ({
id: nextId++,
name,
brand,
categoryId,
storeCount,
itemCount: storeCount,
favorite: false,
...extra
});
// Categories: 1 kip, 2 tapas (both top 10), 3 vis (top 10), 4 melk, 5 chocolade (top 20), 6 pasta, 7 sauzen
const TOP: Record<number, number> = { 1: 10, 2: 11, 3: 10, 4: 12, 5: 20, 6: 13, 7: 14 };
const synonyms = synonymMap([
{ term: 'kipdij', synonym: 'kippendij' },
{ term: 'kippendij', synonym: 'kipdij' },
{ term: 'pasta', synonym: 'spaghetti' },
{ term: 'pasta', synonym: 'penne' }
]);
const names = (q: string, cands: Candidate[]) =>
rank(cands, {
words: expand(queryWords(q), synonyms),
topOf: (id) => (id == null ? null : (TOP[id] ?? id))
}).map((r) => `${r.name} (${r.brand})`);
describe('grocery search ranking', () => {
it('puts plain store-brand kipdijfilet above doner, spiesjes and madras', () => {
const out = names('kipdij', [
c('Kipdij doner', 'Wahid', 1, 4),
c('Kipdijspiesjes Madras', 'Kips', 2, 3),
c('Kipdij-spies', 'Bits & Bites', 2),
c('Kipdijspiesjes', 'Kips', 2, 2),
c('Kipdijfiletreepjes', 'Huismerk', 1),
c('Kipdijfilet', 'Huismerk biologisch', 1, 2),
c('Kipdijfilet', 'Hoogvliet', 3),
c('Kipdijshoarma', 'Huismerk', 1),
c('Scharrel kipdijfilet', 'Huismerk', 1)
]);
expect(out[0]).toBe('Kipdijfilet (Huismerk biologisch)');
expect(out.slice(0, 4).sort()).toEqual([
'Kipdijfilet (Hoogvliet)',
'Kipdijfilet (Huismerk biologisch)',
'Kipdijfiletreepjes (Huismerk)',
'Scharrel kipdijfilet (Huismerk)'
]);
expect(out.indexOf('Kipdijfiletreepjes (Huismerk)')).toBeLessThan(
out.indexOf('Kipdij doner (Wahid)')
);
for (const flavoured of [
'Kipdij doner (Wahid)',
'Kipdijspiesjes Madras (Kips)',
'Kipdijshoarma (Huismerk)'
])
expect(out.indexOf(flavoured)).toBeGreaterThan(
out.indexOf('Scharrel kipdijfilet (Huismerk)')
);
expect(out.at(-1)).toBe('Kipdijspiesjes Madras (Kips)');
});
it('finds spelling variants through synonyms', () => {
const out = names('kippendij', [
c('Kipdijfilet', 'Huismerk', 1),
c('Kippenpoten', 'Huismerk', 1)
]);
expect(out[0]).toBe('Kipdijfilet (Huismerk)');
});
it('ranks milk above chocolate with melk in the name', () => {
const out = names('melk', [
c('Melk chocoladereep', 'Verkade', 5, 5),
c('Chocoladereep melk', "Côte d'Or", 5, 6),
c('Melk', "Tony's Chocolonely", 5, 2),
c('Halfvolle melk', 'Huismerk', 4, 3),
c('Volle melk', 'Huismerk', 4, 3),
c('Halfvolle melk', 'Campina', 4, 5)
]);
expect(out.slice(0, 2).sort()).toEqual(['Halfvolle melk (Huismerk)', 'Volle melk (Huismerk)']);
expect(out[2]).toBe('Halfvolle melk (Campina)');
});
it('treats head-final compounds as the product (zilvervliesrijst is rijst)', () => {
const rijst = textScore({ name: 'Zilvervliesrijst', brand: 'Huismerk' }, [['rijst']]);
const wafel = textScore({ name: 'Rijstwafels', brand: 'Huismerk' }, [['rijst']]);
expect(rijst.text - rijst.penalty).toBeGreaterThan(wafel.text - wafel.penalty);
});
it('expands pasta to pasta shapes and prefers them over ready meals', () => {
const out = names('pasta', [
c('Good pasta carbonara', 'Knorr', 7, 6),
c('Pasta Frutta F!ZZ', 'Haribo', 5, 5),
c('Spaghetti', 'Huismerk', 6, 3),
c('Penne', 'Huismerk', 6, 2)
]);
expect(out.slice(0, 2)).toEqual(['Spaghetti (Huismerk)', 'Penne (Huismerk)']);
});
it('keeps the plain product first for tofu', () => {
const out = names('tofu', [
c('Gerookte Tofu', 'Vivera', 1, 5),
c('Tofu met Rooksmaak', 'Huismerk', 1),
c('Tofu', 'Vivera', 1, 4),
c('Tofu Naturel', 'Huismerk', 1)
]);
expect(out.slice(0, 2).sort()).toEqual(['Tofu (Vivera)', 'Tofu Naturel (Huismerk)']);
});
it('scores words, brands and multi-word queries sensibly', () => {
expect(wordPenalty('madras')).toBeGreaterThan(wordPenalty('scharrel'));
expect(wordPenalty('filet')).toBe(0);
expect(wordPenalty('48+')).toBe(0);
expect(wordPenalty('chocoladereep')).toBeGreaterThan(10);
expect(brandBonus('Huismerk')).toBeGreaterThan(brandBonus('Campina'));
expect(brandBonus('1 de Beste')).toBeGreaterThan(0);
expect(brandBonus(null)).toBeGreaterThan(0);
const two = textScore({ name: 'Halfvolle melk', brand: 'Huismerk' }, [['halfvolle'], ['melk']]);
const one = textScore({ name: 'Halfvolle melk', brand: 'Huismerk' }, [['melk']]);
expect(two.penalty).toBeLessThan(one.penalty + 0.01);
expect(queryWords('Kip-dij Filet')).toEqual(['kip', 'dij', 'filet']);
});
it('can put promotions first without losing relevance order', () => {
const out = rank(
[
c('Kipdijshoarma', 'Huismerk', 1, 1, { promoCount: 1 }),
c('Kipdijfilet', 'Huismerk', 1),
c('Kipdij doner', 'Wahid', 1, 1, { promoCount: 2 })
],
{ words: [['kipdij']], topOf: (id) => id },
{ promoFirst: true }
).map((r) => r.name);
expect(out).toEqual(['Kipdij doner', 'Kipdijshoarma', 'Kipdijfilet']);
});
});

View File

@@ -0,0 +1,274 @@
// Relevance ranking for grocery search: plain/basic products first, flavoured and specialised
// ones further down. Pure scoring (tested in search.test.ts) plus a small DB layer that
// fetches candidates and the synonyms table.
//
// Score = text match on the core name (brand and size stripped)
// - complexity (extra words, flavour/processing words; lists in search-words.ts)
// + bonuses (store brand, the query's dominant category, sold in more stores, favourite)
// + trigram similarity as a tiebreak.
import type { PendingQuery, Row, Sql } from 'postgres';
import { fold, nameTokens, stem } from './normalize.js';
import { BASE_SUFFIXES, MODIFIERS, STORE_BRAND_PREMIUM, STORE_BRANDS } from './search-words.js';
export type Candidate = {
id: number;
name: string;
brand: string | null;
categoryId: number | null;
storeCount: number;
itemCount: number;
favorite: boolean;
promoCount?: number;
/** pg_trgm word similarity of the query to the name, 0..1 */
sim?: number;
};
export type Scored = Candidate & { score: number; text: number; penalty: number };
/** term → extra search terms (folded). One-directional; seed both ways for spelling variants. */
export type Synonyms = Map<string, string[]>;
const SUFFIXES = new Map(Object.entries(BASE_SUFFIXES).map(([w, v]) => [stem(fold(w)), v]));
const MODS = new Map(Object.entries(MODIFIERS).map(([w, v]) => [stem(fold(w)), v]));
const EXTRA_WORD = 10;
const MOD_WORD = 22;
const SYN_FACTOR = 0.92;
/** Query → folded words (max 6). */
export function queryWords(q: string): string[] {
return fold(q)
.replace(/['’]/g, '')
.split(/[^a-z0-9%+]+/)
.filter(Boolean)
.slice(0, 6);
}
/** Each query word with its synonyms: [[word, syn, …], …] (folded, deduped). */
export function expand(words: string[], synonyms: Synonyms): string[][] {
return words.map((w) => [...new Set([w, ...(synonyms.get(w) ?? [])])]);
}
/** Cost of a glued run of base suffixes ("filet" 0, "filetblokje" 5, "" 0); null if not one. */
function suffixCost(r: string, depth = 0): number | null {
if (!r) return 0;
if (depth > 3) return null;
let best: number | null = null;
for (const [s, v] of SUFFIXES)
if (r.startsWith(s)) {
const rest = suffixCost(r.slice(s.length), depth + 1);
if (rest != null && (best == null || v + rest < best)) best = v + rest;
}
return best;
}
/** Penalty for a leftover word or compound part (small or 0 for base suffixes). */
export function wordPenalty(w: string): number {
if (!w || w.length < 2 || /^\d+[%+]?$/.test(w)) return 0;
const s = stem(w);
const sc = suffixCost(s) ?? suffixCost(w);
if (sc != null) return sc;
const m = MODS.get(s) ?? MODS.get(w);
if (m != null) return m * MOD_WORD;
// Compounds containing a flavour/processing word (madrasfilet, chocoladereep, grillworst).
let m2 = 0;
for (const [k, v] of MODS) if (v >= 0.5 && k.length > 3 && s.includes(k)) m2 = Math.max(m2, v);
return m2 ? Math.max(m2 * MOD_WORD, EXTRA_WORD) : EXTRA_WORD;
}
type Hit = { level: number; token: number; leftover: string; cost: number };
/** Best match of one term against the core tokens (stemmed). */
function matchTerm(term: string, tokens: string[]): Hit | null {
let best: Hit | null = null;
const take = (h: Hit) => {
if (!best || h.level - h.cost > best.level - best.cost) best = h;
};
tokens.forEach((t, i) => {
for (const e of new Set([term, stem(term)])) {
if (e.length < 2) continue;
const at = t.indexOf(e);
if (at < 0) continue;
const after = t.slice(at + e.length);
const cost = suffixCost(after);
if (at === 0 && cost != null) take({ level: 100, token: i, leftover: '', cost });
else if (at > 0 && cost != null)
// Dutch compounds are head-final: zilvervliesrijst is rijst, scharrelkipdijfilet is kipdij.
take({ level: 80, token: i, leftover: t.slice(0, at), cost });
else if (at === 0)
// kipdijspiesjes: starts with the query but is something else.
take({ level: 60, token: i, leftover: after, cost: 0 });
else take({ level: 40, token: i, leftover: t.slice(0, at) + ' ' + after, cost: 0 });
}
});
return best;
}
/** Text score (0..106: 100 exact or query+suffix, 80 compound end, 60 prefix, 40 inside, +6 first word) and complexity penalty. */
export function textScore(c: Pick<Candidate, 'name' | 'brand'>, words: string[][]) {
const tokens = nameTokens(c.name, c.brand);
const brandToks = new Set(
fold(c.brand ?? '')
.split(/[^a-z0-9]+/)
.filter(Boolean)
);
const used = new Set<number>();
let total = 0;
let penalty = 0;
let first = false;
for (const alts of words) {
let best: (Hit & { score: number }) | null = null;
alts.forEach((term, k) => {
const h = matchTerm(term, tokens);
if (!h) return;
const score = h.level * (k === 0 ? 1 : SYN_FACTOR);
if (!best || score - h.cost > best.score - best.cost) best = { ...h, score };
});
if (best) {
const b = best as Hit & { score: number };
total += b.score;
used.add(b.token);
if (b.token === 0) first = true;
penalty += b.cost;
for (const part of b.leftover.split(' ')) penalty += wordPenalty(part);
} else if (alts.some((a) => brandToks.has(a))) total += 70;
}
tokens.forEach((t, i) => {
if (!used.has(i)) penalty += wordPenalty(t);
});
const text = words.length ? total / words.length : 0;
return { text: text + (first && text > 0 ? 6 : 0), penalty: Math.min(penalty, 70) };
}
/** Store/private-label bonus from the group's brand label. */
export function brandBonus(brand: string | null): number {
const b = fold(brand ?? '').trim();
if (!b) return 12; // loose produce
if (STORE_BRAND_PREMIUM.includes(b)) return 6;
if (b === 'huismerk' || b === 'huismerk basic') return 15;
if (b.startsWith('huismerk')) return 10;
if (STORE_BRANDS.some((p) => b === p || b.startsWith(p + ' '))) return 12;
return 0;
}
export type Ctx = {
words: string[][];
/** category id → its top-level id */
topOf: (id: number | null) => number | null;
};
/** Score and sort candidates (best first). */
export function rank(cands: Candidate[], ctx: Ctx, opts: { promoFirst?: boolean } = {}): Scored[] {
const pre = cands.map((c) => ({ c, ...textScore(c, ctx.words) }));
// Dominant category: the most common one among strong name matches; plain products (few
// extra words) and products sold in more stores decide what the query is about.
const dominant = (min: number) => {
const w = new Map<number, number>();
for (const p of pre)
if (p.text >= min && p.c.categoryId != null)
w.set(
p.c.categoryId,
(w.get(p.c.categoryId) ?? 0) + (1 + p.c.storeCount) / (1 + p.penalty / 5) ** 2
);
return [...w].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
};
const dom = dominant(90) ?? dominant(75) ?? dominant(55);
const domTop = ctx.topOf(dom);
const out = pre.map(({ c, text, penalty }) => {
let score = text - penalty;
if (text === 0) score = (c.sim ?? 0) * 30 - 40; // matched only via brand/category/other names
score += brandBonus(c.brand);
if (dom != null) {
if (c.categoryId === dom) score += 12;
else if (domTop != null && ctx.topOf(c.categoryId) === domTop) score += 5;
else score -= 10;
}
score += Math.min(Math.max(c.storeCount - 1, 0), 5) * 4;
score += Math.min(Math.log2(1 + c.itemCount), 4);
if (c.favorite) score += 10;
score += (c.sim ?? 0) * 5;
return {
...c,
score: Math.round(score * 100) / 100,
text,
penalty: Math.round(penalty * 10) / 10
};
});
return out.sort(
(a, b) =>
(opts.promoFirst ? Number((b.promoCount ?? 0) > 0) - Number((a.promoCount ?? 0) > 0) : 0) ||
b.score - a.score ||
b.storeCount - a.storeCount ||
b.itemCount - a.itemCount ||
a.name.length - b.name.length ||
a.id - b.id
);
}
// ---- DB --------------------------------------------------------------------------------------
type SearchCtx = { at: number; synonyms: Synonyms; parent: Map<number, number | null> };
let cache: SearchCtx | null = null;
const TTL = 5 * 60_000;
/** Synonyms table + category parents, cached for 5 minutes. */
export async function searchContext(s: Sql): Promise<SearchCtx> {
if (cache && Date.now() - cache.at < TTL) return cache;
const [syn, cats] = await Promise.all([
s<{ term: string; synonym: string }[]>`select term, synonym from groceries.synonyms`,
s<{ id: number; parent_id: number | null }[]>`select id, parent_id from groceries.categories`
]);
cache = {
at: Date.now(),
synonyms: synonymMap(syn),
parent: new Map(cats.map((c) => [c.id, c.parent_id]))
};
return cache;
}
export function synonymMap(rows: { term: string; synonym: string }[]): Synonyms {
const m: Synonyms = new Map();
for (const r of rows) {
const t = fold(r.term).trim();
m.set(t, [...(m.get(t) ?? []), fold(r.synonym).trim()]);
}
return m;
}
const like = (w: string) => '%' + w.replace(/[%_\\]/g, '\\$&') + '%';
/** WHERE fragments: every query word (or one of its synonyms) appears in g.search_text. */
export function searchConds(s: Sql, words: string[][]) {
return words.map((alts) =>
alts.length === 1
? s`g.search_text like ${like(alts[0])}`
: s`g.search_text like any(${alts.map(like)}::text[])`
);
}
const MAX_CANDIDATES = 4000;
/**
* Rank the groups matching `where` (alias g) for query `q`, best first. Up to MAX_CANDIDATES;
* groups whose name contains a query word are fetched first so broad queries keep them.
*/
export async function rankedGroups(
s: Sql,
where: PendingQuery<Row[]>,
q: string,
words: string[][],
opts: { promoFirst?: boolean; ctx?: SearchCtx } = {}
): Promise<Scored[]> {
const ctx = opts.ctx ?? (await searchContext(s));
const nameLike = words.flat().map(like);
const cands = await s<Candidate[]>`
select g.id, g.name, g.brand, g.category_id as "categoryId", g.store_count as "storeCount",
g.item_count as "itemCount", g.favorite, g.promo_count as "promoCount",
public.word_similarity(${q}::text, lower(g.name))::float8 as sim
from groceries.groups g where ${where}
order by (lower(g.name) like any(${nameLike}::text[])) desc, g.store_count desc, g.item_count desc
limit ${MAX_CANDIDATES}`;
const topOf = (id: number | null) => (id == null ? null : (ctx.parent.get(id) ?? id));
return rank(cands, { words, topOf }, opts);
}
/** rankedGroups(), ids only. */
export const rankedIds = async (...a: Parameters<typeof rankedGroups>) =>
(await rankedGroups(...a)).map((c) => c.id);