category according to the agent

This commit is contained in:
2026-09-08 10:36:14 +05:30
parent fdecfe6cc1
commit 1a37949bbe
5 changed files with 147 additions and 11 deletions

View File

@@ -41,6 +41,7 @@ import {
/** How the category was arrived at, in the operator's words. */
const RULE_LABEL: Record<CategoryRule, string> = {
catalogue: 'from the catalogue',
sheet: 'from your sheet',
registry: 'matched by name',
lexicon: 'matched as a commodity',

View File

@@ -2,10 +2,12 @@ import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import {
CATEGORY_REGISTRY,
categoryForCatalogueProduct,
commodityCategory,
detectCategoryFromText,
findMatches,
genericTermFor,
isCanonicalCategory,
isCategoryCode,
LEXICON_WORDS,
resolveCategory,
@@ -167,3 +169,59 @@ test('the registry and the lexicon do not overlap', () => {
assert.equal(registryWords.has(noun), false, `"${noun}" is in both layers`);
}
});
/* ── The catalogue's own value, gated on the published list ──────────────── */
test('a canonical catalogue category is trusted over the keywords', () => {
// It comes from the same pipeline and is better informed than a keyword match
// on a title, so when it IS one of the 31 it wins.
const out = categoryForCatalogueProduct({
catalogueCategory: 'Dairy',
title: 'Amul Something Unrecognisable',
});
assert.equal(out.category, 'Dairy');
assert.equal(out.rule, 'catalogue');
});
test('a catalogue category outside the 31 is not used', () => {
/*
The live catalogue carries "Food - Mixes", "Pickles & Chutneys",
"Dairy - Desserts" and "Health Drinks" — 71 of 200 rows sampled. Taking those
verbatim files a shop's products under names the platform does not have and
no other shop shares, so the product is classified here instead.
*/
for (const outside of ['Food - Mixes', 'Pickles & Chutneys', 'Dairy - Desserts', 'Health Drinks']) {
assert.equal(isCanonicalCategory(outside), false, outside);
}
const out = categoryForCatalogueProduct({
catalogueCategory: 'Food - Mixes',
title: 'Aachi Biryani Mix 100g',
});
assert.equal(out.category, 'Spices & Masalas');
assert.notEqual(out.rule, 'catalogue');
});
test('the canonical name is returned in the registry casing', () => {
// Two shops must not end up with "dairy" and "Dairy" as separate aisles.
const out = categoryForCatalogueProduct({ catalogueCategory: ' dairy ', title: 'Anything' });
assert.equal(out.category, 'Dairy');
});
test('a catalogue product with no category falls through to the ladder', () => {
const out = categoryForCatalogueProduct({ catalogueCategory: '', title: 'Toor Dal 1kg' });
assert.equal(out.category, 'Pulses, Grains & Spices');
assert.equal(out.rule, 'registry');
});
test('the shapes the non-canonical names were covering now land somewhere sane', () => {
const at = (title: string) => categoryForCatalogueProduct({ catalogueCategory: 'Food - Mixes', title }).category;
assert.equal(at('Aachi Chettinad Mix 250g'), 'Spices & Masalas');
assert.equal(
categoryForCatalogueProduct({ catalogueCategory: 'Pickles & Chutneys', title: 'Aachi Mango Pickle 300g' }).category,
'Spices & Masalas',
);
assert.equal(
categoryForCatalogueProduct({ catalogueCategory: 'Dairy - Desserts', title: 'Amul Ice Cream Cup 100ml' }).category,
'Dairy',
);
});

View File

@@ -88,7 +88,7 @@ export const CATEGORY_REGISTRY: readonly CategoryEntry[] = [
},
{
category: 'Beverages',
keywords: ['beverage', 'beverages', 'drink', 'drinks', 'juice', 'soda', 'cola', 'coffee', 'tea', 'squash', 'syrup', 'health drink', 'horlicks', 'bournvita', 'boost', 'water', 'soft drink'],
keywords: ['beverage', 'beverages', 'drink', 'drinks', 'juice', 'soda', 'cola', 'coffee', 'tea', 'squash', 'syrup', 'health drink', 'health drinks', 'horlicks', 'bournvita', 'boost', 'complan', 'water', 'soft drink', 'malt drink'],
genericTerm: 'beverage',
},
{
@@ -103,7 +103,7 @@ export const CATEGORY_REGISTRY: readonly CategoryEntry[] = [
},
{
category: 'Spices & Masalas',
keywords: ['spice', 'spices', 'masala', 'masalas', 'turmeric', 'haldi', 'chilli powder', 'chili powder', 'cumin', 'jeera', 'coriander powder', 'dhania', 'garam masala', 'sambar powder', 'rasam powder', 'pepper', 'cardamom', 'clove', 'cinnamon', 'asafoetida', 'hing', 'fenugreek', 'mustard seed', 'curry powder', 'kuzhambu', 'kulambu', 'garlic paste', 'ginger garlic', 'ginger paste', 'paste', 'podi', 'powder'],
keywords: ['spice', 'spices', 'masala', 'masalas', 'turmeric', 'haldi', 'chilli powder', 'chili powder', 'cumin', 'jeera', 'coriander powder', 'dhania', 'garam masala', 'sambar powder', 'rasam powder', 'pepper', 'cardamom', 'clove', 'cinnamon', 'asafoetida', 'hing', 'fenugreek', 'mustard seed', 'curry powder', 'kuzhambu', 'kulambu', 'garlic paste', 'ginger garlic', 'ginger paste', 'paste', 'podi', 'powder', 'mix', 'biryani', 'chettinad', 'pickle', 'chutney', 'thokku'],
genericTerm: 'spice',
},
{
@@ -119,7 +119,7 @@ export const CATEGORY_REGISTRY: readonly CategoryEntry[] = [
},
{
category: 'Dairy',
keywords: ['milk', 'curd', 'yoghurt', 'yogurt', 'butter', 'ghee', 'cheese', 'paneer', 'cream', 'dairy', 'buttermilk', 'lassi', 'khoa'],
keywords: ['milk', 'curd', 'yoghurt', 'yogurt', 'butter', 'ghee', 'cheese', 'paneer', 'cream', 'dairy', 'buttermilk', 'lassi', 'khoa', 'ice cream', 'kulfi', 'yoghurt drink'],
genericTerm: 'dairy',
},
{
@@ -174,6 +174,54 @@ export const CATEGORY_REGISTRY: readonly CategoryEntry[] = [
},
];
/** The 31 names, for checking whether a value is one of them. */
const CANONICAL = new Set(CATEGORY_REGISTRY.map((entry) => entry.category.toLowerCase()));
/**
* Is this one of the catalogue team's 31 categories?
*
* Their list is the classification; anything outside it is not a category this
* platform recognises, however sensible it looks. The live catalogue carries
* several — "Food - Mixes", "Pickles & Chutneys", "Dairy - Desserts",
* "Health Drinks" — on about a third of the rows sampled, and taking those
* verbatim would file a shop's products under names the app has never heard of
* and that no other shop shares.
*/
export function isCanonicalCategory(name: string | null | undefined): boolean {
return CANONICAL.has((name ?? '').trim().toLowerCase());
}
/**
* The category to use for a product the catalogue already categorises.
*
* The catalogue's answer wins WHEN IT IS ONE OF THE 31 — it comes from the same
* pipeline and is better informed than a keyword match on a title. When it is
* not, the name is classified here instead, so every product this console files
* lands in the published list rather than inventing a 32nd aisle.
*/
export function categoryForCatalogueProduct(input: {
catalogueCategory?: string | null;
title: string;
description?: string;
packSize?: string;
}): CategoryVerdict {
const theirs = (input.catalogueCategory ?? '').trim();
if (isCanonicalCategory(theirs)) {
// Returned in the registry's own casing, so two shops never end up with
// "dairy" and "Dairy" as separate aisles.
const canonical = CATEGORY_REGISTRY.find(
(entry) => entry.category.toLowerCase() === theirs.toLowerCase(),
);
return { category: canonical?.category ?? theirs, rule: 'catalogue' };
}
return resolveCategory({
title: input.title,
description: input.description ?? '',
packSize: input.packSize ?? '',
});
}
/* ── Rule 1: the sheet's own value ───────────────────────────────────────── */
/**
@@ -297,7 +345,7 @@ export function commodityCategory(title: string, packSize = ''): string | null {
/* ── The ladder ──────────────────────────────────────────────────────────── */
export type CategoryRule = 'sheet' | 'registry' | 'lexicon' | 'unknown';
export type CategoryRule = 'catalogue' | 'sheet' | 'registry' | 'lexicon' | 'unknown';
export interface CategoryVerdict {
category: string;