- Updated `SupplierAnalysisResult` to include a `product` field and modified related tests. - Refactored `addRowsSheet` to accommodate changes in the product structure. - Enhanced UPC file analysis to utilize a new `toSupplierInputRecord` function for cleaner record creation. - Introduced new types for supplier input records and product observations. - Updated frontend components to handle new product details and analysis history. - Improved database writing functions to streamline run completion and error handling. - Added new API endpoints for product details and adjusted routing in the frontend.
269 lines
7.8 KiB
TypeScript
269 lines
7.8 KiB
TypeScript
import { db } from "../db/index.ts";
|
|
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
|
|
import { sql } from "drizzle-orm";
|
|
import { normalizeAsin } from "../asin.ts";
|
|
import { analyzeProducts } from "../integrations/llm.ts";
|
|
import { fetchSpApiPricingAndFees } from "../integrations/sp-api.ts";
|
|
import type {
|
|
AnalysisResult,
|
|
EnrichedProduct,
|
|
KeepaData,
|
|
ProductRecord,
|
|
SellabilityInfo,
|
|
} from "../types.ts";
|
|
|
|
const LLM_BATCH_SIZE = 5;
|
|
const LLM_BATCH_DELAY_MS = 5_000;
|
|
|
|
type Args = {
|
|
stalkerRunId: number;
|
|
analysisRunId: number;
|
|
asins: string[];
|
|
useClaude: boolean;
|
|
};
|
|
|
|
type InventoryRow = {
|
|
inventoryItemId: number;
|
|
asin: string;
|
|
productTitle: string | null;
|
|
brand: string | null;
|
|
categoryTree: string | null;
|
|
currentPrice: number | null;
|
|
avgPrice90d: number | null;
|
|
salesRank: number | null;
|
|
monthlySold: number | null;
|
|
sellerCount: number | null;
|
|
amazonIsSeller: boolean | null;
|
|
canSell: boolean | null;
|
|
sellabilityStatus: SellabilityInfo["sellabilityStatus"] | null;
|
|
sellabilityReason: string | null;
|
|
};
|
|
|
|
function readFlagValue(args: string[], flag: string): string | undefined {
|
|
const index = args.indexOf(flag);
|
|
if (index === -1) return undefined;
|
|
return args[index + 1];
|
|
}
|
|
|
|
function parseArgs(argv = process.argv.slice(2)): Args {
|
|
const stalkerRunId = Number(readFlagValue(argv, "--stalker-run-id"));
|
|
const analysisRunId = Number(readFlagValue(argv, "--analysis-run-id"));
|
|
const useClaude = argv.includes("--claude");
|
|
const asins = (readFlagValue(argv, "--asins") ?? "")
|
|
.split(",")
|
|
.map((asin) => normalizeAsin(asin))
|
|
.filter((asin): asin is string => asin !== null);
|
|
|
|
if (!Number.isInteger(stalkerRunId) || stalkerRunId <= 0) {
|
|
throw new Error("--stalker-run-id must be a positive integer");
|
|
}
|
|
if (!Number.isInteger(analysisRunId) || analysisRunId <= 0) {
|
|
throw new Error("--analysis-run-id must be a positive integer");
|
|
}
|
|
if (asins.length === 0) throw new Error("Missing --asins");
|
|
|
|
return { stalkerRunId, analysisRunId, asins, useClaude };
|
|
}
|
|
|
|
function wait(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function parseCategoryTree(value: string | null): string[] {
|
|
return value ? value.split(" > ").filter(Boolean) : [];
|
|
}
|
|
|
|
function toProductRecord(row: InventoryRow): ProductRecord {
|
|
const categoryTree = parseCategoryTree(row.categoryTree);
|
|
return {
|
|
asin: row.asin,
|
|
name: row.productTitle ?? row.asin,
|
|
brand: row.brand ?? undefined,
|
|
category: categoryTree.join(" > ") || undefined,
|
|
unitCost: 0,
|
|
amazonRank: row.salesRank ?? undefined,
|
|
sellingPriceFromSheet: row.currentPrice ?? undefined,
|
|
avgPrice90FromSheet: row.avgPrice90d ?? undefined,
|
|
};
|
|
}
|
|
|
|
function toKeepaData(row: InventoryRow): KeepaData {
|
|
return {
|
|
currentPrice: row.currentPrice,
|
|
avgPrice90: row.avgPrice90d,
|
|
minPrice90: null,
|
|
maxPrice90: null,
|
|
salesRank: row.salesRank,
|
|
salesRankAvg90: null,
|
|
salesRankDrops30: null,
|
|
salesRankDrops90: null,
|
|
sellerCount: row.sellerCount,
|
|
amazonIsSeller: row.amazonIsSeller,
|
|
amazonBuyboxSharePct90d: null,
|
|
buyBoxSeller: null,
|
|
buyBoxPrice: null,
|
|
buyBoxAvg90: null,
|
|
monthlySold: row.monthlySold,
|
|
categoryTree: parseCategoryTree(row.categoryTree),
|
|
};
|
|
}
|
|
|
|
function toSellability(row: InventoryRow): SellabilityInfo {
|
|
return {
|
|
canSell: row.canSell,
|
|
sellabilityStatus: row.sellabilityStatus ?? "unknown",
|
|
sellabilityReason: row.sellabilityReason ?? undefined,
|
|
};
|
|
}
|
|
|
|
async function loadInventoryRows(
|
|
stalkerRunId: number,
|
|
asins: string[],
|
|
): Promise<InventoryRow[]> {
|
|
if (asins.length === 0) return [];
|
|
return db.execute(
|
|
sql<InventoryRow>`SELECT DISTINCT ON (inventory.product_asin)
|
|
inventory.id AS "inventoryItemId",
|
|
inventory.product_asin AS asin,
|
|
product.name AS "productTitle",
|
|
product.brand,
|
|
product.category AS "categoryTree",
|
|
observation.current_price AS "currentPrice",
|
|
observation.avg_price_90d AS "avgPrice90d",
|
|
observation.sales_rank AS "salesRank",
|
|
observation.monthly_sold AS "monthlySold",
|
|
observation.seller_count AS "sellerCount",
|
|
observation.amazon_is_seller AS "amazonIsSeller",
|
|
observation.can_sell AS "canSell",
|
|
observation.sellability_status AS "sellabilityStatus",
|
|
observation.sellability_reason AS "sellabilityReason"
|
|
FROM stalker_inventory_items inventory
|
|
JOIN products product ON product.asin = inventory.product_asin
|
|
JOIN product_observations observation ON observation.id = inventory.observation_id
|
|
WHERE inventory.run_id = ${stalkerRunId}
|
|
AND observation.can_sell = true
|
|
AND observation.sellability_status = 'available'
|
|
AND inventory.product_asin = ANY(${asins})
|
|
ORDER BY inventory.product_asin, observation.fetched_at DESC`,
|
|
);
|
|
}
|
|
|
|
async function buildEnrichedProducts(
|
|
rows: InventoryRow[],
|
|
): Promise<EnrichedProduct[]> {
|
|
const enriched: EnrichedProduct[] = [];
|
|
|
|
for (const row of rows) {
|
|
const sellability = toSellability(row);
|
|
const spApi = await fetchSpApiPricingAndFees(
|
|
row.asin,
|
|
sellability,
|
|
row.currentPrice,
|
|
);
|
|
|
|
enriched.push({
|
|
record: toProductRecord(row),
|
|
keepa: toKeepaData(row),
|
|
spApi,
|
|
fetchedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
return enriched;
|
|
}
|
|
|
|
async function insertProductAnalysisResults(
|
|
runId: number,
|
|
results: AnalysisResult[],
|
|
sourceInventoryIds: Map<string, number>,
|
|
): Promise<void> {
|
|
if (results.length === 0) return;
|
|
await persistLlmResults(runId, results, {
|
|
source: "stalker_analysis",
|
|
metadataSource: "catalog",
|
|
sourceInventoryIds,
|
|
});
|
|
}
|
|
|
|
async function refreshAnalysisRun(runId: number): Promise<void> {
|
|
await refreshRunStats(runId);
|
|
}
|
|
|
|
async function analyzeInBatches(
|
|
products: EnrichedProduct[],
|
|
useClaude: boolean,
|
|
): Promise<AnalysisResult[]> {
|
|
const results: AnalysisResult[] = [];
|
|
|
|
for (let i = 0; i < products.length; i += LLM_BATCH_SIZE) {
|
|
const batch = products.slice(i, i + LLM_BATCH_SIZE);
|
|
const batchNumber = Math.floor(i / LLM_BATCH_SIZE) + 1;
|
|
const totalBatches = Math.ceil(products.length / LLM_BATCH_SIZE);
|
|
console.log(
|
|
`Stalker analysis: LLM batch ${batchNumber}/${totalBatches} (${batch.length} product(s)).`,
|
|
);
|
|
|
|
if (i > 0) {
|
|
await wait(LLM_BATCH_DELAY_MS);
|
|
}
|
|
|
|
let verdicts;
|
|
try {
|
|
verdicts = await analyzeProducts(batch, { useClaude });
|
|
} catch (error) {
|
|
console.warn(
|
|
`Stalker analysis: LLM batch ${batchNumber} failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
verdicts = null;
|
|
}
|
|
|
|
for (let j = 0; j < batch.length; j++) {
|
|
const product = batch[j];
|
|
if (!product) continue;
|
|
results.push({
|
|
product,
|
|
verdict: verdicts?.[j] ?? {
|
|
asin: product.record.asin,
|
|
verdict: "SKIP",
|
|
confidence: 0,
|
|
reasoning: "LLM analysis failed or returned no verdict",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = parseArgs();
|
|
|
|
const rows = await loadInventoryRows(args.stalkerRunId, args.asins);
|
|
if (rows.length === 0) {
|
|
console.log("Stalker analysis: no sellable inventory rows to analyze.");
|
|
return;
|
|
}
|
|
|
|
console.log(`Stalker analysis: analyzing ${rows.length} sellable ASIN(s).`);
|
|
const enriched = await buildEnrichedProducts(rows);
|
|
const results = await analyzeInBatches(enriched, args.useClaude);
|
|
const sourceInventoryIds = new Map(
|
|
rows.map((row) => [row.asin, row.inventoryItemId]),
|
|
);
|
|
await insertProductAnalysisResults(
|
|
args.analysisRunId,
|
|
results,
|
|
sourceInventoryIds,
|
|
);
|
|
await refreshAnalysisRun(args.analysisRunId);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|
|
}
|