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

@@ -12,6 +12,7 @@ import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { useSelection } from '@/components/useSelection';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
@@ -319,6 +320,23 @@ export function CatalogueBrowser({
* A request per product would open one connection per row from a shop's
* browser — the mistake the catalogue reconciliation already documents.
*/
/**
* The category NAME for one catalogue product, always one of the 31.
*
* The catalogue's own value is used when it is canonical; when it is not —
* "Food - Mixes", "Pickles & Chutneys", "Dairy - Desserts" and the rest,
* about a third of the rows sampled — the product is classified by the ladder
* instead, so nothing is filed under a name the published list does not have.
*/
function categoryNameFor(product: CatalogueProduct): string {
return categoryForCatalogueProduct({
catalogueCategory: product.category,
title: product.product_name ?? '',
description: product.description ?? '',
packSize: product.size ?? '',
}).category;
}
async function categoryIdsFor(products: CatalogueProduct[]): Promise<Map<string, number>> {
const out = new Map<string, number>();
const fallback = Number(chosenCategory);
@@ -328,7 +346,7 @@ export function CatalogueBrowser({
return out;
}
const names = [...new Set(products.map((p) => (p.category ?? '').trim()).filter(Boolean))];
const names = [...new Set(products.map(categoryNameFor).filter(Boolean))];
let resolved: Record<string, number> = {};
if (names.length > 0) {
try {
@@ -339,7 +357,7 @@ export function CatalogueBrowser({
}
for (const product of products) {
const name = (product.category ?? '').trim().toLowerCase();
const name = categoryNameFor(product).toLowerCase();
out.set(catalogueKey(product), resolved[name] ?? fallback);
}
return out;
@@ -348,7 +366,7 @@ export function CatalogueBrowser({
async function categoryIdFor(product: CatalogueProduct): Promise<number> {
if (importInto !== null && importInto !== '') return Number(importInto);
const name = (product.category ?? '').trim();
const name = categoryNameFor(product);
if (!name) return Number(chosenCategory);
try {

View File

@@ -13,6 +13,7 @@ import {
Row,
Section,
} from '@/features/store-admin/drawerKit';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
/**
@@ -62,6 +63,14 @@ export function CatalogueDetailDrawer({
blockedReason,
onClose,
}: CatalogueDetailDrawerProps) {
/* Which of the 31 this product will actually be filed under, and why. */
const suggested = categoryForCatalogueProduct({
catalogueCategory: product.category,
title: product.product_name,
description: product.description ?? '',
packSize: product.size ?? '',
});
const images = product.images ?? [];
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
@@ -276,11 +285,13 @@ export function CatalogueDetailDrawer({
showing shoppers an empty shop, while the console listed
their stock as normal. A wrongly filed product is at least
findable and fixable; an unfiled one was neither. */
placeholder={product.category ? `Catalogue: ${product.category}` : 'Choose a category'}
placeholder={`Suggested: ${suggested.category}`}
description={
product.category
? `Left alone, this is filed under ${product.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.`
: 'The catalogue does not categorise this one, so pick where it belongs. A product with no category cannot appear in the customer app at all.'
suggested.rule === 'catalogue'
? `Left alone, this is filed under ${suggested.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it goes to ${suggested.category} instead. Choose one here to override.`
: `The catalogue does not categorise this one, so it goes to ${suggested.category}. Choose one here to override.`
}
/>
) : null}

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;