Files
asin-check/src/stalker/stalker-analyze.ts
Victor Noguera 923ebbaec5 Refactor supplier analysis and product handling
- 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.
2026-05-25 12:27:41 -04:00

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);
});
}