Refactor SP-API test script and improve type definitions
- Updated `sp-test.ts` to enhance argument parsing and error handling for sellability checks. - Refactored `types.ts` to maintain consistent formatting and improve readability. - Improved `writer.ts` for better result handling and CSV writing, ensuring clarity in output. - Adjusted `tsconfig.json` formatting for consistency and readability.
This commit is contained in:
706
src/llm.ts
706
src/llm.ts
@@ -1,353 +1,353 @@
|
||||
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<LlmVerdict[]> {
|
||||
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<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.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<string, unknown>)
|
||||
: 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<string, unknown> => !!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<string, LlmVerdict>();
|
||||
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",
|
||||
};
|
||||
});
|
||||
}
|
||||
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<LlmVerdict[]> {
|
||||
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<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.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<string, unknown>)
|
||||
: 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<string, unknown> => !!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<string, LlmVerdict>();
|
||||
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",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user