Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions apps/importer/src/shopee/__tests__/entity-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,141 @@ async function runEntityMatcherSuite() {
assert.equal(ctrlEntry.platform, 'shopee');
assert.equal(ctrlEntry.event, 'evt name');
console.log(' ✅ [9/9] DOS/Rate#876 EntityMatcher & Logger control-char/boolean bounds passed');

// 10. DOS/Rate#1142: masters keep only the spec fields their category has.
const enrichByCategory = async (
row: Record<string, unknown>,
aiResult: Record<string, unknown>
): Promise<Record<string, unknown> | undefined> => {
let updated: Record<string, unknown> | undefined;
const db = {
from() {
return {
select: () => ({
eq: () => ({ maybeSingle: async () => ({ data: row, error: null }) }),
}),
update(payload: Record<string, unknown>) {
updated = payload;
return { eq: async () => ({ error: null }) };
},
};
},
} as unknown as RateSupabaseClient;
const enricher = { fetchSpecsFromAi: async () => aiResult } as unknown as AiSpecEnricher;
assert.equal(
await new EntityMatcher(db, enricher).enrichMasterProductById(String(row.id)),
true
);
return updated;
};
const phoneShapedAi = {
ram_gb: 1,
storage_gb: 64,
screen_size_inch: 1,
battery_mah: 515,
has_5g: true,
specifications: {
display: { size_inch: 1, refresh_rate_hz: 60, ppi: 300 },
hardware: { ram_gb: 1, storage_gb: 64, chipset: 'BES2700' },
camera: { main_camera: '3 High-SNR Microphones' },
cellular: { has_5g: true, bands_5g: 'n78', bands_4g: 'LTE' },
battery: { capacity_mah: 515 },
multimedia: { audio_features: 'ANC, LDAC' },
connectivity: { bluetooth: '5.4', wlan: 'Auto Switch' },
},
key_specs: { screen: '1"', camera: 'Mic', ram: '1GB', storage: '64GB', battery: '515 mAh' },
};
const budsRow = await enrichByCategory(
{
id: 'buds-1142',
brand: 'Samsung',
model: 'Galaxy Buds3 Pro',
name: 'Samsung Galaxy Buds3 Pro',
ram_gb: 1,
storage_gb: 64,
screen_size_inch: 1,
has_5g: false,
specifications: { category_slug: 'earbuds', display: { size_inch: 1 } },
key_specs: { ram: '1GB' },
},
phoneShapedAi
);
assert.equal(budsRow?.ram_gb, null, 'earbuds keep no RAM, even one stored before');
assert.equal(budsRow?.storage_gb, null);
assert.equal(budsRow?.screen_size_inch, null);
assert.equal(budsRow?.has_5g, false);
assert.equal(budsRow?.battery_mah, 515);
const budsSpecs = budsRow?.specifications as Record<string, Record<string, unknown>>;
assert.equal(budsSpecs.display, undefined);
assert.deepEqual(budsSpecs.hardware, { chipset: 'BES2700' }, 'the audio chip stays');
assert.equal(budsSpecs.camera, undefined);
assert.equal(budsSpecs.cellular, undefined);
assert.equal(budsSpecs.connectivity?.wlan, undefined);
assert.equal(budsSpecs.connectivity?.bluetooth, '5.4');
assert.equal(budsSpecs.multimedia?.audio_features, 'ANC, LDAC');
assert.equal(budsSpecs.category_slug, 'earbuds');
assert.deepEqual(budsRow?.key_specs, { battery: '515 mAh' });

const watchRow = await enrichByCategory(
{
id: 'watch-1142',
brand: 'Apple',
model: 'Watch Ultra 2',
name: 'Apple Watch Ultra 2 64GB',
storage_gb: 64,
has_5g: true,
specifications: { category_slug: 'smartwatches' },
key_specs: {},
},
phoneShapedAi
);
// A watch keeps its stored 5G facts (a few models have RedCap 5G); only the
// page hides them, and the camera it cannot have is dropped.
assert.equal(watchRow?.has_5g, true);
assert.equal(watchRow?.storage_gb, 64);
const watchSpecs = watchRow?.specifications as Record<string, Record<string, unknown>>;
assert.equal(watchSpecs.camera, undefined);
assert.equal(watchSpecs.cellular?.has_5g, true);
assert.equal(watchSpecs.cellular?.bands_5g, 'n78');
assert.equal(watchSpecs.cellular?.bands_4g, 'LTE');
assert.equal(watchSpecs.display?.size_inch, 1);

// A category guessed from the title does not scope the write: a master
// without a stored category keeps its RAM even when its name says "tai nghe".
const guessedRow = await enrichByCategory(
{
id: 'guess-1142',
brand: 'Samsung',
model: 'Galaxy S25',
name: 'Samsung Galaxy S25 tặng tai nghe Galaxy Buds',
ram_gb: 12,
storage_gb: 256,
has_5g: true,
specifications: {},
key_specs: {},
},
phoneShapedAi
);
assert.equal(guessedRow?.ram_gb, 12);
assert.equal(guessedRow?.storage_gb, 256);
assert.equal(guessedRow?.has_5g, true);

const budsInsertDb = createMockSupabase();
await new EntityMatcher(budsInsertDb.client, offlineEnricher).resolveOrCreateMasterProduct({
...s24New,
model_key: 'samsung:galaxy-buds3-pro',
category_slug: 'earbuds',
ram_gb: 1,
storage_gb: 64,
screen_size_inch: 1,
has_5g: true,
});
const budsInsert = budsInsertDb.capturedUpserts[0]?.payload;
assert.equal(budsInsert?.ram_gb, null);
assert.equal(budsInsert?.storage_gb, null);
assert.equal(budsInsert?.screen_size_inch, null);
assert.equal(budsInsert?.has_5g, false);
console.log(' ✅ [10/10] DOS/Rate#1142 category spec templates on master writes passed');
console.log('🎉 All EntityMatcher & Open-Core Audit C6/C8/T15/Low tests passed!');
}

Expand Down
26 changes: 17 additions & 9 deletions apps/importer/src/shopee/ai-spec-enricher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ProductSpecifications,
type KeySpecs,
} from '@repo/shared-data';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import type { NormalizedProductCandidate } from './product-normalizer.js';
import { computeRetryDelayMs, createImporterLogger, sanitizeAiGeneratedText } from './logger.js';

Expand Down Expand Up @@ -619,6 +620,7 @@ RULES:
3. Boolean fields (has_5g, nfc, volte, vowifi, headphone_jack_35mm) MUST be JSON booleans (true or false).
4. Separate cameras clearly following PhoneArena structure: main_camera, second_camera (ultrawide), third_camera (telephoto/macro), fourth_camera (periscope if present), front_camera.
5. Keep descriptive text concise, accurate, and readable for Vietnamese & international users.
6. Omit groups and fields the device category does not have. Earbuds have no display, RAM, storage, camera or cellular network; describe their drivers, codecs, ANC and battery instead. Smartwatches have no camera, and a cellular watch is LTE unless the model really supports 5G.

REQUIRED JSON SCHEMA:
{
Expand Down Expand Up @@ -977,15 +979,21 @@ REQUIRED JSON SCHEMA:
const keySpecsParsed = KeySpecsSchema.safeParse(keySpecsCandidate);
const keySpecs: KeySpecs = keySpecsParsed.success ? keySpecsParsed.data : {};

const result: AiEnrichmentResponse = {
ram_gb: ramGb,
storage_gb: storageGb,
screen_size_inch: screenSizeInch,
battery_mah: batteryMah,
has_5g: has5g,
specifications,
key_specs: keySpecs,
};
// The schema is phone-shaped; drop what the category does not have
// (earbuds: no screen, RAM or camera; watches: no 5G) (DOS/Rate#1142).
const result: AiEnrichmentResponse = applyCategorySpecTemplate(
catKey,
{
ram_gb: ramGb,
storage_gb: storageGb,
screen_size_inch: screenSizeInch,
battery_mah: batteryMah,
has_5g: has5g,
specifications,
key_specs: keySpecs,
},
{ forStorage: true }
);

this.setCacheEntry(cacheKey, result);
logger.info(
Expand Down
132 changes: 75 additions & 57 deletions apps/importer/src/shopee/entity-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type CanonicalCategorySlug,
type NormalizedProductCandidate,
} from './product-normalizer.js';
import { applyCategorySpecTemplate } from '@repo/shared-data/rules';
import { AiSpecEnricher, mergeProductSpecifications } from './ai-spec-enricher.js';
import { mergeKeySpecs } from './jobs/backfill-specs.js';
import { createImporterLogger } from './logger.js';
Expand Down Expand Up @@ -298,54 +299,60 @@ export class EntityMatcher {
.replace(/[^a-z0-9-]+/gi, '-')
.toLowerCase()
.slice(0, 300);
const baseInsertRow = {
category_id: safeCategoryId || null,
brand:
String(candidate.brand ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 100) || 'Unknown',
model:
String(candidate.model ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 200) || 'Unknown',
variant_name: candidate.variant_name
? String(candidate.variant_name)
// Only the spec fields the category has: no screen or RAM on earbuds, no
// 5G on a watch (DOS/Rate#1142).
const baseInsertRow = applyCategorySpecTemplate(
candidate.category_slug,
{
category_id: safeCategoryId || null,
brand:
String(candidate.brand ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 100) || 'Unknown',
model:
String(candidate.model ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 200) || null
: null,
model_key:
String(candidate.model_key ?? '')
.replace(/\p{Cc}+/gu, '')
.trim()
.slice(0, 200) || safeModelKey,
name:
String(candidate.name ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 300) || safeModelKey,
slug: safeSlug,
description:
typeof candidate.description === 'string'
? candidate.description
.slice(0, 200) || 'Unknown',
variant_name: candidate.variant_name
? String(candidate.variant_name)
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 10_000)
: '',
ram_gb: toBoundedPositiveInt(candidate.ram_gb, MAX_RAM_GB),
storage_gb: toBoundedPositiveInt(candidate.storage_gb, MAX_STORAGE_GB),
screen_size_inch: toBoundedPositiveNumber(candidate.screen_size_inch, MAX_SCREEN_SIZE_INCH),
battery_mah: toBoundedPositiveInt(candidate.battery_mah, MAX_BATTERY_MAH),
has_5g: isStrictBoolean(candidate.has_5g),
specifications: candidate.specifications,
key_specs: candidate.key_specs,
images: safeImages,
thumbnail: safeImages[0] || null,
colors: safeColors,
status: seedStatus,
};
.slice(0, 200) || null
: null,
model_key:
String(candidate.model_key ?? '')
.replace(/\p{Cc}+/gu, '')
.trim()
.slice(0, 200) || safeModelKey,
name:
String(candidate.name ?? '')
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 300) || safeModelKey,
slug: safeSlug,
description:
typeof candidate.description === 'string'
? candidate.description
.replace(/\p{Cc}+/gu, ' ')
.trim()
.slice(0, 10_000)
: '',
ram_gb: toBoundedPositiveInt(candidate.ram_gb, MAX_RAM_GB),
storage_gb: toBoundedPositiveInt(candidate.storage_gb, MAX_STORAGE_GB),
screen_size_inch: toBoundedPositiveNumber(candidate.screen_size_inch, MAX_SCREEN_SIZE_INCH),
battery_mah: toBoundedPositiveInt(candidate.battery_mah, MAX_BATTERY_MAH),
has_5g: isStrictBoolean(candidate.has_5g),
specifications: candidate.specifications,
key_specs: candidate.key_specs,
images: safeImages,
thumbnail: safeImages[0] || null,
colors: safeColors,
status: seedStatus,
},
{ forStorage: true }
);

let { data: createdRows, error: createErr } = await this.supabase
.from('master_products')
Expand Down Expand Up @@ -525,19 +532,30 @@ export class EntityMatcher {

const { error: updateErr } = await this.supabase
.from('master_products')
.update({
ram_gb: existingRamGb ?? toBoundedPositiveInt(aiResult.ram_gb, MAX_RAM_GB),
storage_gb: existingStorageGb ?? toBoundedPositiveInt(aiResult.storage_gb, MAX_STORAGE_GB),
screen_size_inch:
existingScreenInch ??
toBoundedPositiveNumber(aiResult.screen_size_inch, MAX_SCREEN_SIZE_INCH),
battery_mah:
existingBatteryMah ?? toBoundedPositiveInt(aiResult.battery_mah, MAX_BATTERY_MAH),
has_5g: isStrictBoolean(row.has_5g) ? true : isStrictBoolean(aiResult.has_5g ?? row.has_5g),
specifications: mergedSpecifications,
key_specs: mergedKeySpecs,
updated_at: new Date().toISOString(),
})
.update(
// Only a stored category scopes the write: a guess from the title
// could strip a phone's real RAM.
applyCategorySpecTemplate(
existingCategorySlug,
{
ram_gb: existingRamGb ?? toBoundedPositiveInt(aiResult.ram_gb, MAX_RAM_GB),
storage_gb:
existingStorageGb ?? toBoundedPositiveInt(aiResult.storage_gb, MAX_STORAGE_GB),
screen_size_inch:
existingScreenInch ??
toBoundedPositiveNumber(aiResult.screen_size_inch, MAX_SCREEN_SIZE_INCH),
battery_mah:
existingBatteryMah ?? toBoundedPositiveInt(aiResult.battery_mah, MAX_BATTERY_MAH),
has_5g: isStrictBoolean(row.has_5g)
? true
: isStrictBoolean(aiResult.has_5g ?? row.has_5g),
specifications: mergedSpecifications,
key_specs: mergedKeySpecs,
updated_at: new Date().toISOString(),
},
{ forStorage: true }
)
)
.eq('id', safeMasterId);

if (updateErr) {
Expand Down
Loading
Loading