Refactor mid-range seller processing to enforce sellability gates and enhance command-line arguments

- Updated test case to reflect changes in processing mid-range matches based on sellability.
- Modified `processCategory` function to implement strict and soft sellability gates.
- Introduced new command-line arguments for category selection and sellability gate configuration.
- Enhanced error handling and validation for new arguments.
- Improved logging for category processing and budget usage.
This commit is contained in:
Victor Noguera
2026-05-12 14:14:20 -04:00
parent f2c8a9728d
commit 41ef57a7bc
5 changed files with 681 additions and 404 deletions

View File

@@ -14,11 +14,14 @@ import type {
export const DEFAULT_LLM_BATCH_SIZE = 5; export const DEFAULT_LLM_BATCH_SIZE = 5;
export const DEFAULT_PRICING_CONCURRENCY = 5; export const DEFAULT_PRICING_CONCURRENCY = 5;
export type SellabilityFilter = "available" | "all";
export type AnalysisPipelineOptions = { export type AnalysisPipelineOptions = {
llmBatchSize?: number; llmBatchSize?: number;
pricingConcurrency?: number; pricingConcurrency?: number;
llmBatchDelayMs?: number; llmBatchDelayMs?: number;
llmRetryDelayMs?: number; llmRetryDelayMs?: number;
sellability?: SellabilityFilter;
}; };
export function chunkArray<T>(items: T[], chunkSize: number): T[][] { export function chunkArray<T>(items: T[], chunkSize: number): T[][] {
@@ -56,6 +59,7 @@ export async function processProductChunk(
); );
const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000); const llmBatchDelayMs = Math.max(0, options.llmBatchDelayMs ?? 5_000);
const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000); const llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000);
const sellabilityFilter = options.sellability ?? "available";
console.log(`\nChecking cache for ${products.length} products...`); console.log(`\nChecking cache for ${products.length} products...`);
const cached = new Map<string, EnrichedProduct>(); const cached = new Map<string, EnrichedProduct>();
@@ -65,7 +69,10 @@ export async function processProductChunk(
for (const p of products) { for (const p of products) {
const hit = await getCache(p.asin); const hit = await getCache(p.asin);
if (hit) { if (hit) {
if (hit.spApi.sellabilityStatus === "available") { if (
sellabilityFilter === "all" ||
hit.spApi.sellabilityStatus === "available"
) {
console.log(` [cache hit] ${p.asin}`); console.log(` [cache hit] ${p.asin}`);
cached.set(p.asin, hit); cached.set(p.asin, hit);
} else { } else {
@@ -103,7 +110,10 @@ export async function processProductChunk(
}; };
sellabilityMap.set(p.asin, info); sellabilityMap.set(p.asin, info);
if (info.sellabilityStatus === "available") { if (
sellabilityFilter === "all" ||
info.sellabilityStatus === "available"
) {
availableProducts.push(p); availableProducts.push(p);
console.log( console.log(
` [available] ${p.asin} - status=${info.sellabilityStatus}`, ` [available] ${p.asin} - status=${info.sellabilityStatus}`,
@@ -116,10 +126,16 @@ export async function processProductChunk(
} }
} }
if (sellabilityFilter === "all") {
console.log(
`\nSellability gate disabled: including all ${availableProducts.length} products`,
);
} else {
console.log( console.log(
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`, `\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
); );
} }
}
let keepaResults = new Map<string, KeepaData>(); let keepaResults = new Map<string, KeepaData>();
if (availableProducts.length > 0) { if (availableProducts.length > 0) {
@@ -224,13 +240,17 @@ export async function processProductChunk(
let verdicts; let verdicts;
try { try {
verdicts = await analyzeProducts(batch); verdicts = await analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all",
});
} catch { } catch {
if (llmRetryDelayMs > 0) { if (llmRetryDelayMs > 0) {
await wait(llmRetryDelayMs); await wait(llmRetryDelayMs);
} }
try { try {
verdicts = await analyzeProducts(batch); verdicts = await analyzeProducts(batch, {
ignoreSellability: sellabilityFilter === "all",
});
} catch { } catch {
verdicts = null; verdicts = null;
} }

View File

@@ -2,27 +2,57 @@ import { readProducts } from "./reader.ts";
import { connectCache, disconnectCache } from "./cache.ts"; import { connectCache, disconnectCache } from "./cache.ts";
import { printResults, writeResultsToDb } from "./writer.ts"; import { printResults, writeResultsToDb } from "./writer.ts";
import { initDb, closeDb } from "./database.ts"; import { initDb, closeDb } from "./database.ts";
import { chunkArray, processProductChunk } from "./analysis-pipeline.ts"; import {
chunkArray,
processProductChunk,
type SellabilityFilter,
} from "./analysis-pipeline.ts";
import path from "node:path"; import path from "node:path";
import type { AnalysisResult } from "./types.ts"; import type { AnalysisResult } from "./types.ts";
const DB_PATH = "./results.db"; const DB_PATH = "./results.db";
const INPUT_BATCH_SIZE = 50; const INPUT_BATCH_SIZE = 50;
function parseArgs(): { inputFile: string; outputFile?: string } { function parseSellabilityArg(args: string[]): SellabilityFilter {
const sellabilityArg = args.find((a) => a.startsWith("--sellability="));
const sellabilityValueFromEquals = sellabilityArg?.split("=")[1];
const sellabilityIdx = args.indexOf("--sellability");
const sellabilityValueFromNext =
sellabilityIdx !== -1 ? args[sellabilityIdx + 1] : undefined;
const rawSellability = sellabilityValueFromEquals ?? sellabilityValueFromNext;
if (!rawSellability) return "available";
const normalized = rawSellability.toLowerCase();
if (normalized === "available" || normalized === "all") {
return normalized;
}
console.error(
`Invalid --sellability value: \"${rawSellability}\". Use \"available\" or \"all\".`,
);
process.exit(1);
}
function parseArgs(): {
inputFile: string;
outputFile?: string;
sellability: SellabilityFilter;
} {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const inputFile = args.find((a) => !a.startsWith("--")); const inputFile = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out"); const outIdx = args.indexOf("--out");
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined; const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
const sellability = parseSellabilityArg(args);
if (!inputFile) { if (!inputFile) {
console.error( console.error(
"Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv]", "Usage: bun run src/index.ts <input.csv|xlsx> [--out results.csv] [--sellability available|all]",
); );
process.exit(1); process.exit(1);
} }
return { inputFile, outputFile }; return { inputFile, outputFile, sellability };
} }
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string { function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
@@ -33,7 +63,9 @@ function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
} }
async function main() { async function main() {
const { inputFile, outputFile } = parseArgs(); const { inputFile, outputFile, sellability } = parseArgs();
console.log(`Sellability filter: ${sellability}`);
console.log("Connecting to Redis..."); console.log("Connecting to Redis...");
await connectCache(); await connectCache();
@@ -66,7 +98,7 @@ async function main() {
console.log( console.log(
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`, `\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
); );
const chunkResults = await processProductChunk(chunk); const chunkResults = await processProductChunk(chunk, { sellability });
allResults.push(...chunkResults); allResults.push(...chunkResults);
} }

View File

@@ -1,7 +1,7 @@
import { config } from "./config.ts"; import { config } from "./config.ts";
import type { EnrichedProduct, LlmVerdict } from "./types.ts"; import type { EnrichedProduct, LlmVerdict } from "./types.ts";
const SYSTEM_PROMPT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy. const SYSTEM_PROMPT_STRICT = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
Given product data, evaluate each product's viability for selling on Amazon. Consider: Given product data, evaluate each product's viability for selling on Amazon. Consider:
@@ -29,11 +29,48 @@ Return ONLY a raw JSON array (no markdown, no code fences, no explanation before
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`; Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., restricted, low demand, thin margin).`;
const SYSTEM_PROMPT_ASSUME_LISTABLE = `You are an expert Amazon product analyst specializing in FBA and FBM fulfillment strategy.
Given product data, evaluate each product's viability for selling on Amazon. Consider:
1. **Sales Velocity**: monthlySold and salesRankDrops30 are the most important signals. A product that doesn't sell is worthless regardless of margin. salesRankDrops30 = approximate units sold in 30 days. monthlySold is Keepa's estimate.
2. **Margin Analysis**: Sale price minus unit cost minus fees (FBA or FBM). Aim for >30% ROI minimum. The spreadsheet may include FBA NET and gross profit estimates — cross-check against Keepa pricing data.
3. **Sales Rank (BSR)**: Lower rank = higher demand. Rank <50,000 is good, <1,000 is excellent.
4. **Sales Rank Trend**: Compare current rank vs 90d average. Lower current = improving demand.
5. **Competition**: Number of sellers and Buy Box dynamics. Fewer sellers = easier entry.
6. **Price Stability**: Large price swings (high max vs low min over 90d) = volatile/risky.
7. **FBA vs FBM**: FBA preferred for fast-selling, small/light items. FBM for oversized, slow-moving, or high-margin items where fee savings matter.
8. **MOQ & Capital**: High MOQ with thin margins is risky.
9. **Supply Availability**: Total quantity available from supplier — low stock means limited runway.
Decision policy:
- Ignore seller eligibility restrictions/status in this run.
- Assume all products are listable by this seller account.
- Prioritize profitable + high-velocity products.
- Use "SKIP" when data quality is poor or risk is high.
Return ONLY a raw JSON array (no markdown, no code fences, no explanation before or after). One verdict per product:
[{ "asin": "B...", "verdict": "FBA" | "FBM" | "SKIP", "confidence": 0-100, "reasoning": "..." }]
Keep each reasoning under 100 characters to stay within output limits and mention key blocker if skipped (e.g., low demand, thin margin).`;
type AnalyzeProductsOptions = {
ignoreSellability?: boolean;
};
function getSystemPrompt(options: AnalyzeProductsOptions): string {
if (options.ignoreSellability) {
return SYSTEM_PROMPT_ASSUME_LISTABLE;
}
return SYSTEM_PROMPT_STRICT;
}
export async function analyzeProducts( export async function analyzeProducts(
products: EnrichedProduct[], products: EnrichedProduct[],
options: AnalyzeProductsOptions = {},
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
try { try {
return await analyzeProductsInternal(products); return await analyzeProductsInternal(products, options);
} catch (err) { } catch (err) {
const msg = String(err); const msg = String(err);
if (products.length > 1 && msg.includes("Context size has been exceeded")) { if (products.length > 1 && msg.includes("Context size has been exceeded")) {
@@ -44,7 +81,7 @@ export async function analyzeProducts(
const fallback: LlmVerdict[] = []; const fallback: LlmVerdict[] = [];
for (const product of products) { for (const product of products) {
try { try {
const single = await analyzeProductsInternal([product]); const single = await analyzeProductsInternal([product], options);
fallback.push( fallback.push(
single[0] ?? { single[0] ?? {
asin: product.record.asin, asin: product.record.asin,
@@ -70,8 +107,12 @@ export async function analyzeProducts(
async function analyzeProductsInternal( async function analyzeProductsInternal(
products: EnrichedProduct[], products: EnrichedProduct[],
options: AnalyzeProductsOptions,
): Promise<LlmVerdict[]> { ): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm); const productSummaries = products.map((p) =>
summarizeForLlm(p, options.ignoreSellability === true),
);
const systemPrompt = getSystemPrompt(options);
const res = await fetch(`${config.llmUrl}/chat/completions`, { const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST", method: "POST",
@@ -82,7 +123,7 @@ async function analyzeProductsInternal(
body: JSON.stringify({ body: JSON.stringify({
model: config.llmModel, model: config.llmModel,
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) }, { role: "user", content: JSON.stringify(productSummaries, null, 2) },
], ],
temperature: 0.3, temperature: 0.3,
@@ -102,7 +143,7 @@ async function analyzeProductsInternal(
return parseVerdicts(content, products); return parseVerdicts(content, products);
} }
function summarizeForLlm(p: EnrichedProduct) { function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {
const salePrice = const salePrice =
p.keepa?.currentPrice ?? p.keepa?.currentPrice ??
p.record.sellingPriceFromSheet ?? p.record.sellingPriceFromSheet ??
@@ -169,9 +210,11 @@ function summarizeForLlm(p: EnrichedProduct) {
referralFee != null ? Math.round(referralFee * 100) / 100 : null, referralFee != null ? Math.round(referralFee * 100) / 100 : null,
}, },
sellerEligibility: { sellerEligibility: {
canSell: p.spApi.canSell, canSell: ignoreSellability ? true : p.spApi.canSell,
status: p.spApi.sellabilityStatus, status: ignoreSellability ? "available" : p.spApi.sellabilityStatus,
reason: clampText(p.spApi.sellabilityReason, 120), reason: ignoreSellability
? "Assumed listable by sellability=all"
: clampText(p.spApi.sellabilityReason, 120),
}, },
estimatedProfit: estimatedProfit:
fbaProfit != null && fbmProfit != null fbaProfit != null && fbmProfit != null

View File

@@ -320,7 +320,7 @@ beforeEach(() => {
}) as unknown as typeof globalThis.fetch; }) as unknown as typeof globalThis.fetch;
}); });
test("processCategory keeps mid-range matches even when sellability is restricted", async () => { test("processCategory only analyzes sellable mid-range matches", async () => {
const mockCategory = { const mockCategory = {
id: 1, id: 1,
label: "Category 1", label: "Category 1",
@@ -363,8 +363,8 @@ test("processCategory keeps mid-range matches even when sellability is restricte
expect(summary.status).toBe("ok"); expect(summary.status).toBe("ok");
expect(summary.topAsinsChecked).toBe(5); expect(summary.topAsinsChecked).toBe(5);
expect(summary.availableAsins).toBe(2); expect(summary.availableAsins).toBe(1);
expect(summary.results?.length).toBe(2); expect(summary.results?.length).toBe(1);
const productResults = db const productResults = db
.query( .query(
@@ -377,15 +377,8 @@ test("processCategory keeps mid-range matches even when sellability is restricte
sellability_status: string; sellability_status: string;
}>; }>;
expect(productResults.length).toBe(2); expect(productResults.length).toBe(1);
expect(productResults.map((row) => row.asin)).toEqual([ expect(productResults.map((row) => row.asin)).toEqual(["B000000001"]);
"B000000003",
"B000000001",
]);
const restricted = productResults.find((row) => row.asin === "B000000003");
expect(restricted?.can_sell).toBe("no");
expect(restricted?.sellability_status).toBe("restricted");
const sellable = productResults.find((row) => row.asin === "B000000001"); const sellable = productResults.find((row) => row.asin === "B000000001");
expect(sellable?.can_sell).toBe("yes"); expect(sellable?.can_sell).toBe("yes");

File diff suppressed because it is too large Load Diff