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. 10. **Seller Eligibility (critical)**: - If sellerEligibility.status is "restricted" or "not_available", return verdict = "SKIP". - If sellerEligibility.status is "unknown", treat as elevated risk and only allow FBA/FBM with clearly strong economics + demand. - If canSell is false, return "SKIP" regardless of margin. Decision policy: - Do not recommend products that cannot be listed by this seller account. - Prioritize profitable + high-velocity + listable 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., restricted, low demand, thin margin).`; export async function analyzeProducts( products: EnrichedProduct[], ): Promise { try { return await analyzeProductsInternal(products); } catch (err) { const msg = String(err); if (products.length > 1 && msg.includes("Context size has been exceeded")) { console.warn( `LLM context exceeded for batch of ${products.length}, retrying one product at a time...`, ); const fallback: LlmVerdict[] = []; for (const product of products) { try { const single = await analyzeProductsInternal([product]); fallback.push( single[0] ?? { asin: product.record.asin, verdict: "SKIP", confidence: 0, reasoning: "LLM returned empty verdict", }, ); } catch { fallback.push({ asin: product.record.asin, verdict: "SKIP", confidence: 0, reasoning: "LLM context overflow on single-item fallback", }); } } return fallback; } throw err; } } async function analyzeProductsInternal( products: EnrichedProduct[], ): Promise { 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.record.sellingPriceFromSheet ?? 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: clampText(p.record.name, 80), brand: p.record.brand, category: clampText( p.record.category ?? p.keepa?.categoryTree?.join(" > "), 60, ), 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: { avgPrice90: p.record.avgPrice90FromSheet, sellingPrice: p.record.sellingPriceFromSheet, fbaNet: p.record.fbaNet, grossProfit: p.record.grossProfit, grossProfitPct: p.record.grossProfitPct, netProfit: p.record.netProfitFromSheet, roi: p.record.roiFromSheet, }, supplier: clampText(p.record.supplier, 40), 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, }, sellerEligibility: { canSell: p.spApi.canSell, status: p.spApi.sellabilityStatus, reason: clampText(p.spApi.sellabilityReason, 120), }, 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 clampText(value: unknown, maxLen: number): string | undefined { if (value == null) return undefined; const s = String(value).trim(); if (!s) return undefined; return s.length > maxLen ? `${s.slice(0, maxLen - 1)}.` : s; } 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(); // Strip any non-JSON wrapper text by taking the largest JSON-looking segment const firstArray = cleaned.indexOf("["); const firstObject = cleaned.indexOf("{"); const startCandidates = [firstArray, firstObject].filter((i) => i >= 0); const start = startCandidates.length > 0 ? Math.min(...startCandidates) : -1; const endArray = cleaned.lastIndexOf("]"); const endObject = cleaned.lastIndexOf("}"); const end = Math.max(endArray, endObject); if (start >= 0 && end > start) { cleaned = cleaned.slice(start, end + 1); } // Fix trailing comma-quote before closing brace: ,"} → "} cleaned = cleaned.replace(/,"\s*}/g, '"}'); // Fix malformed comma-quote before a closing bracket/brace: ,"} or ,"] cleaned = cleaned.replace(/,\s*"\s*([}\]])/g, "$1"); // Fix malformed quote-comma before a closing bracket/brace: ",} or ",] cleaned = cleaned.replace(/"\s*,\s*([}\]])/g, '"$1'); // Fix trailing commas before ] or } cleaned = cleaned.replace(/,\s*([}\]])/g, "$1"); return cleaned; } function parseVerdicts( content: string, products: EnrichedProduct[], ): LlmVerdict[] { const cleaned = cleanLlmJson(content); try { const parsed = JSON.parse(cleaned) as unknown; return alignVerdicts(products, normalizeVerdicts(parsed)); } catch (err) { const salvaged = extractVerdictsLoosely(cleaned); if (salvaged.length > 0) { console.warn( `LLM response was invalid JSON; salvaged ${salvaged.length} verdict(s) with loose parsing.`, ); return alignVerdicts(products, salvaged); } 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`, })); } } function normalizeVerdicts(parsed: unknown): LlmVerdict[] { const container = parsed && typeof parsed === "object" ? (parsed as Record) : undefined; const nested = container?.verdicts ?? container?.results; const arr: unknown[] = Array.isArray(parsed) ? parsed : Array.isArray(nested) ? nested : [parsed]; return arr .filter((v): v is Record => !!v && typeof v === "object") .map((v) => ({ asin: String(v.asin ?? "") .trim() .toUpperCase(), verdict: (String(v.verdict).toUpperCase() === "FBA" || String(v.verdict).toUpperCase() === "FBM" || String(v.verdict).toUpperCase() === "SKIP" ? String(v.verdict).toUpperCase() : "SKIP") as LlmVerdict["verdict"], confidence: clampConfidence( typeof v.confidence === "number" ? v.confidence : Number(v.confidence ?? 0), ), reasoning: String(v.reasoning ?? "No reasoning provided"), })); } function extractVerdictsLoosely(text: string): LlmVerdict[] { const objectMatches = text.match(/\{[\s\S]*?\}/g) ?? []; const verdicts: LlmVerdict[] = []; for (const chunk of objectMatches) { const asin = extractField(chunk, /"asin"\s*:\s*"?([A-Z0-9]{10})"?/i) ?? ""; const verdictRaw = extractField(chunk, /"verdict"\s*:\s*"?([A-Z]+)"?/i) ?? "SKIP"; const confidenceRaw = extractField(chunk, /"confidence"\s*:\s*([0-9]+(?:\.[0-9]+)?)/i) ?? "0"; const reasoning = extractField(chunk, /"reasoning"\s*:\s*"([\s\S]*?)"\s*(?:,|})/i) ?? "No reasoning provided"; const normalizedVerdict = verdictRaw.toUpperCase(); if (!asin) continue; verdicts.push({ asin, verdict: (normalizedVerdict === "FBA" || normalizedVerdict === "FBM" || normalizedVerdict === "SKIP" ? normalizedVerdict : "SKIP") as LlmVerdict["verdict"], confidence: clampConfidence(Number(confidenceRaw)), reasoning, }); } return verdicts; } function extractField(text: string, regex: RegExp): string | undefined { const match = text.match(regex); return match?.[1]?.trim(); } function clampConfidence(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } function alignVerdicts( products: EnrichedProduct[], verdicts: LlmVerdict[], ): LlmVerdict[] { const byAsin = new Map(); for (const verdict of verdicts) { if (verdict.asin && !byAsin.has(verdict.asin)) { byAsin.set(verdict.asin, verdict); } } return products.map((product, index) => { const asin = product.record.asin; const byAsinVerdict = byAsin.get(asin); if (byAsinVerdict) return { ...byAsinVerdict, asin }; const byIndexVerdict = verdicts[index]; if (byIndexVerdict) return { ...byIndexVerdict, asin }; return { asin, verdict: "SKIP" as const, confidence: 0, reasoning: "LLM returned no verdict for this product", }; }); }