Files
asin-check/src/llm.ts
Victor Noguera 061f771279 feat: initialize asin-check project with Bun
- Add README.md with installation and usage instructions.
- Create bun.lock for dependency management.
- Add package.json to define project metadata and dependencies.
- Implement caching with Redis in cache.ts for ASIN data.
- Configure environment variables in config.ts for API keys and Redis URL.
- Develop main application logic in index.ts to read products, fetch data, and analyze results.
- Integrate Keepa API for product data retrieval in keepa.ts.
- Create LLM analysis functionality in llm.ts for product viability assessment.
- Implement product reading from Excel files in reader.ts.
- Stub SP-API integration in sp-api.ts for future implementation.
- Define TypeScript types in types.ts for product and analysis data structures.
- Write results to console and CSV in writer.ts.
- Configure TypeScript settings in tsconfig.json for project compilation.
2026-04-04 21:33:27 -04:00

161 lines
5.9 KiB
TypeScript

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.
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.
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.`;
export async function analyzeProducts(
products: EnrichedProduct[],
): Promise<LlmVerdict[]> {
const productSummaries = products.map(summarizeForLlm);
const res = await fetch(`${config.llmUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer lm-studio",
},
body: JSON.stringify({
model: config.llmModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify(productSummaries, null, 2) },
],
temperature: 0.3,
max_tokens: 2048,
}),
});
if (!res.ok) {
throw new Error(`LLM API error ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content ?? "";
return parseVerdicts(content, products);
}
function summarizeForLlm(p: EnrichedProduct) {
const salePrice = p.keepa?.currentPrice ?? p.spApi.estimatedSalePrice;
const referralFee = salePrice * (p.spApi.referralFeePercent / 100);
const fbaProfit =
salePrice - p.record.unitCost - p.spApi.fbaFee - referralFee;
const fbmProfit =
salePrice - p.record.unitCost - p.spApi.fbmFee - referralFee;
return {
asin: p.record.asin,
name: p.record.name,
brand: p.record.brand,
category: p.record.category ?? p.keepa?.categoryTree?.join(" > "),
unitCost: p.record.unitCost,
currentPrice: salePrice,
priceRange90d: p.keepa
? {
min: p.keepa.minPrice90,
max: p.keepa.maxPrice90,
avg: p.keepa.avgPrice90,
}
: null,
salesRank: p.keepa?.salesRank ?? p.record.amazonRank,
salesRankAvg90d: p.keepa?.salesRankAvg90,
sellerCount: p.keepa?.sellerCount,
salesVelocity: {
monthlySold: p.keepa?.monthlySold,
salesRankDrops30: p.keepa?.salesRankDrops30,
salesRankDrops90: p.keepa?.salesRankDrops90,
},
spreadsheetEstimates: {
fbaNet: p.record.fbaNet,
grossProfit: p.record.grossProfit,
grossProfitPct: p.record.grossProfitPct,
},
moq: p.record.moq,
moqCost: p.record.moqCost,
totalQtyAvail: p.record.totalQtyAvail,
fees: {
fbaFee: p.spApi.fbaFee,
fbmFee: p.spApi.fbmFee,
referralFeePercent: p.spApi.referralFeePercent,
referralFee: Math.round(referralFee * 100) / 100,
},
estimatedProfit: {
fba: Math.round(fbaProfit * 100) / 100,
fbm: Math.round(fbmProfit * 100) / 100,
},
estimatedROI: {
fba:
p.record.unitCost > 0
? Math.round((fbaProfit / p.record.unitCost) * 100)
: null,
fbm:
p.record.unitCost > 0
? Math.round((fbmProfit / p.record.unitCost) * 100)
: null,
},
};
}
function cleanLlmJson(text: string): string {
// Remove ```json ... ``` or ``` ... ``` wrapping
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
let cleaned = fenceMatch ? fenceMatch[1]!.trim() : text.trim();
// Fix trailing comma-quote before closing brace: ,"} → "}
cleaned = cleaned.replace(/,"\s*}/g, '"}');
// Fix trailing commas before ] or }
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
return cleaned;
}
function parseVerdicts(
content: string,
products: EnrichedProduct[],
): LlmVerdict[] {
try {
const cleaned = cleanLlmJson(content);
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed)
? parsed
: (parsed.verdicts ?? parsed.results ?? [parsed]);
return arr.map((v: Record<string, unknown>) => ({
asin: String(v.asin ?? ""),
verdict: (["FBA", "FBM", "SKIP"].includes(String(v.verdict))
? v.verdict
: "SKIP") as LlmVerdict["verdict"],
confidence: typeof v.confidence === "number" ? v.confidence : 0,
reasoning: String(v.reasoning ?? "No reasoning provided"),
}));
} catch (err) {
console.warn("Failed to parse LLM response, marking all as ANALYSIS_FAILED");
console.warn("Raw LLM content:", content.slice(0, 500));
return products.map((p) => ({
asin: p.record.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: `Analysis failed: could not parse LLM output`,
}));
}
}