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:
@@ -14,11 +14,14 @@ import type {
|
||||
export const DEFAULT_LLM_BATCH_SIZE = 5;
|
||||
export const DEFAULT_PRICING_CONCURRENCY = 5;
|
||||
|
||||
export type SellabilityFilter = "available" | "all";
|
||||
|
||||
export type AnalysisPipelineOptions = {
|
||||
llmBatchSize?: number;
|
||||
pricingConcurrency?: number;
|
||||
llmBatchDelayMs?: number;
|
||||
llmRetryDelayMs?: number;
|
||||
sellability?: SellabilityFilter;
|
||||
};
|
||||
|
||||
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 llmRetryDelayMs = Math.max(0, options.llmRetryDelayMs ?? 10_000);
|
||||
const sellabilityFilter = options.sellability ?? "available";
|
||||
|
||||
console.log(`\nChecking cache for ${products.length} products...`);
|
||||
const cached = new Map<string, EnrichedProduct>();
|
||||
@@ -65,7 +69,10 @@ export async function processProductChunk(
|
||||
for (const p of products) {
|
||||
const hit = await getCache(p.asin);
|
||||
if (hit) {
|
||||
if (hit.spApi.sellabilityStatus === "available") {
|
||||
if (
|
||||
sellabilityFilter === "all" ||
|
||||
hit.spApi.sellabilityStatus === "available"
|
||||
) {
|
||||
console.log(` [cache hit] ${p.asin}`);
|
||||
cached.set(p.asin, hit);
|
||||
} else {
|
||||
@@ -103,7 +110,10 @@ export async function processProductChunk(
|
||||
};
|
||||
sellabilityMap.set(p.asin, info);
|
||||
|
||||
if (info.sellabilityStatus === "available") {
|
||||
if (
|
||||
sellabilityFilter === "all" ||
|
||||
info.sellabilityStatus === "available"
|
||||
) {
|
||||
availableProducts.push(p);
|
||||
console.log(
|
||||
` [available] ${p.asin} - status=${info.sellabilityStatus}`,
|
||||
@@ -116,9 +126,15 @@ export async function processProductChunk(
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
|
||||
);
|
||||
if (sellabilityFilter === "all") {
|
||||
console.log(
|
||||
`\nSellability gate disabled: including all ${availableProducts.length} products`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\nSellability gate: ${availableProducts.length} available, ${unavailableProducts.length} excluded`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let keepaResults = new Map<string, KeepaData>();
|
||||
@@ -224,13 +240,17 @@ export async function processProductChunk(
|
||||
|
||||
let verdicts;
|
||||
try {
|
||||
verdicts = await analyzeProducts(batch);
|
||||
verdicts = await analyzeProducts(batch, {
|
||||
ignoreSellability: sellabilityFilter === "all",
|
||||
});
|
||||
} catch {
|
||||
if (llmRetryDelayMs > 0) {
|
||||
await wait(llmRetryDelayMs);
|
||||
}
|
||||
try {
|
||||
verdicts = await analyzeProducts(batch);
|
||||
verdicts = await analyzeProducts(batch, {
|
||||
ignoreSellability: sellabilityFilter === "all",
|
||||
});
|
||||
} catch {
|
||||
verdicts = null;
|
||||
}
|
||||
|
||||
44
src/index.ts
44
src/index.ts
@@ -2,27 +2,57 @@ import { readProducts } from "./reader.ts";
|
||||
import { connectCache, disconnectCache } from "./cache.ts";
|
||||
import { printResults, writeResultsToDb } from "./writer.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 type { AnalysisResult } from "./types.ts";
|
||||
|
||||
const DB_PATH = "./results.db";
|
||||
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 inputFile = args.find((a) => !a.startsWith("--"));
|
||||
const outIdx = args.indexOf("--out");
|
||||
const outputFile = outIdx !== -1 ? args[outIdx + 1] : undefined;
|
||||
const sellability = parseSellabilityArg(args);
|
||||
|
||||
if (!inputFile) {
|
||||
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);
|
||||
}
|
||||
|
||||
return { inputFile, outputFile };
|
||||
return { inputFile, outputFile, sellability };
|
||||
}
|
||||
|
||||
function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
||||
@@ -33,7 +63,9 @@ function resolveBaseOutputPath(inputFile: string, outputFile?: string): string {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { inputFile, outputFile } = parseArgs();
|
||||
const { inputFile, outputFile, sellability } = parseArgs();
|
||||
|
||||
console.log(`Sellability filter: ${sellability}`);
|
||||
|
||||
console.log("Connecting to Redis...");
|
||||
await connectCache();
|
||||
@@ -66,7 +98,7 @@ async function main() {
|
||||
console.log(
|
||||
`\n=== Input chunk ${chunkIndex + 1}/${productChunks.length} (${chunk.length} products) ===`,
|
||||
);
|
||||
const chunkResults = await processProductChunk(chunk);
|
||||
const chunkResults = await processProductChunk(chunk, { sellability });
|
||||
allResults.push(...chunkResults);
|
||||
}
|
||||
|
||||
|
||||
61
src/llm.ts
61
src/llm.ts
@@ -1,7 +1,7 @@
|
||||
import { config } from "./config.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:
|
||||
|
||||
@@ -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).`;
|
||||
|
||||
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(
|
||||
products: EnrichedProduct[],
|
||||
options: AnalyzeProductsOptions = {},
|
||||
): Promise<LlmVerdict[]> {
|
||||
try {
|
||||
return await analyzeProductsInternal(products);
|
||||
return await analyzeProductsInternal(products, options);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (products.length > 1 && msg.includes("Context size has been exceeded")) {
|
||||
@@ -44,7 +81,7 @@ export async function analyzeProducts(
|
||||
const fallback: LlmVerdict[] = [];
|
||||
for (const product of products) {
|
||||
try {
|
||||
const single = await analyzeProductsInternal([product]);
|
||||
const single = await analyzeProductsInternal([product], options);
|
||||
fallback.push(
|
||||
single[0] ?? {
|
||||
asin: product.record.asin,
|
||||
@@ -70,8 +107,12 @@ export async function analyzeProducts(
|
||||
|
||||
async function analyzeProductsInternal(
|
||||
products: EnrichedProduct[],
|
||||
options: AnalyzeProductsOptions,
|
||||
): 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`, {
|
||||
method: "POST",
|
||||
@@ -82,7 +123,7 @@ async function analyzeProductsInternal(
|
||||
body: JSON.stringify({
|
||||
model: config.llmModel,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
|
||||
],
|
||||
temperature: 0.3,
|
||||
@@ -102,7 +143,7 @@ async function analyzeProductsInternal(
|
||||
return parseVerdicts(content, products);
|
||||
}
|
||||
|
||||
function summarizeForLlm(p: EnrichedProduct) {
|
||||
function summarizeForLlm(p: EnrichedProduct, ignoreSellability: boolean) {
|
||||
const salePrice =
|
||||
p.keepa?.currentPrice ??
|
||||
p.record.sellingPriceFromSheet ??
|
||||
@@ -169,9 +210,11 @@ function summarizeForLlm(p: EnrichedProduct) {
|
||||
referralFee != null ? Math.round(referralFee * 100) / 100 : null,
|
||||
},
|
||||
sellerEligibility: {
|
||||
canSell: p.spApi.canSell,
|
||||
status: p.spApi.sellabilityStatus,
|
||||
reason: clampText(p.spApi.sellabilityReason, 120),
|
||||
canSell: ignoreSellability ? true : p.spApi.canSell,
|
||||
status: ignoreSellability ? "available" : p.spApi.sellabilityStatus,
|
||||
reason: ignoreSellability
|
||||
? "Assumed listable by sellability=all"
|
||||
: clampText(p.spApi.sellabilityReason, 120),
|
||||
},
|
||||
estimatedProfit:
|
||||
fbaProfit != null && fbmProfit != null
|
||||
|
||||
@@ -320,7 +320,7 @@ beforeEach(() => {
|
||||
}) 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 = {
|
||||
id: 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.topAsinsChecked).toBe(5);
|
||||
expect(summary.availableAsins).toBe(2);
|
||||
expect(summary.results?.length).toBe(2);
|
||||
expect(summary.availableAsins).toBe(1);
|
||||
expect(summary.results?.length).toBe(1);
|
||||
|
||||
const productResults = db
|
||||
.query(
|
||||
@@ -377,15 +377,8 @@ test("processCategory keeps mid-range matches even when sellability is restricte
|
||||
sellability_status: string;
|
||||
}>;
|
||||
|
||||
expect(productResults.length).toBe(2);
|
||||
expect(productResults.map((row) => row.asin)).toEqual([
|
||||
"B000000003",
|
||||
"B000000001",
|
||||
]);
|
||||
|
||||
const restricted = productResults.find((row) => row.asin === "B000000003");
|
||||
expect(restricted?.can_sell).toBe("no");
|
||||
expect(restricted?.sellability_status).toBe("restricted");
|
||||
expect(productResults.length).toBe(1);
|
||||
expect(productResults.map((row) => row.asin)).toEqual(["B000000001"]);
|
||||
|
||||
const sellable = productResults.find((row) => row.asin === "B000000001");
|
||||
expect(sellable?.can_sell).toBe("yes");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user