feat: add product distributor research table and integrate distributor analysis in Stalker product workflow
This commit is contained in:
@@ -16,6 +16,15 @@
|
|||||||
"Bash(bun run build:web 2>&1 || true)",
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
"Bash(bun run build:web 2>&1 || true)",
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
"Bash(bun run build:web 2>&1 || true)",
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run db:migrate 2>&1 || true)",
|
||||||
|
"Bash(git --no-pager diff -- src/web/frontend.tsx src/web/styles.css 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
"Bash(bun run build:web 2>&1 || true)"
|
"Bash(bun run build:web 2>&1 || true)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
23
drizzle/0001_product_distributor_research.sql
Normal file
23
drizzle/0001_product_distributor_research.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "product_distributor_research" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"product_asin" text NOT NULL,
|
||||||
|
"run_item_id" integer,
|
||||||
|
"inventory_item_id" integer,
|
||||||
|
"provider" text DEFAULT 'claude' NOT NULL,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'completed' NOT NULL,
|
||||||
|
"query_context_json" text,
|
||||||
|
"distributors_json" text,
|
||||||
|
"raw_response" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_product_asin_products_asin_fk" FOREIGN KEY ("product_asin") REFERENCES "public"."products"("asin") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_run_item_id_run_items_id_fk" FOREIGN KEY ("run_item_id") REFERENCES "public"."run_items"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_distributor_research" ADD CONSTRAINT "product_distributor_research_inventory_item_id_stalker_inventory_items_id_fk" FOREIGN KEY ("inventory_item_id") REFERENCES "public"."stalker_inventory_items"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_distributor_research_asin_time" ON "product_distributor_research" USING btree ("product_asin","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_distributor_research_run_item" ON "product_distributor_research" USING btree ("run_item_id");
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1779726518779,
|
"when": 1779726518779,
|
||||||
"tag": "0000_adorable_shiver_man",
|
"tag": "0000_adorable_shiver_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780000000000,
|
||||||
|
"tag": "0001_product_distributor_research",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -439,3 +439,34 @@ export const stalkerInventoryItems = pgTable(
|
|||||||
index("idx_stalker_inventory_product_asin").on(t.productAsin),
|
index("idx_stalker_inventory_product_asin").on(t.productAsin),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const productDistributorResearch = pgTable(
|
||||||
|
"product_distributor_research",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
productAsin: text("product_asin")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.asin, { onDelete: "cascade" }),
|
||||||
|
runItemId: integer("run_item_id").references(
|
||||||
|
(): AnyPgColumn => runItems.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
inventoryItemId: integer("inventory_item_id").references(
|
||||||
|
(): AnyPgColumn => stalkerInventoryItems.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
provider: text("provider").notNull().default("claude"),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
status: text("status").notNull().default("completed"),
|
||||||
|
queryContextJson: text("query_context_json"),
|
||||||
|
distributorsJson: text("distributors_json"),
|
||||||
|
rawResponse: text("raw_response"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_distributor_research_asin_time").on(t.productAsin, t.createdAt),
|
||||||
|
index("idx_distributor_research_run_item").on(t.runItemId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
261
src/server.ts
261
src/server.ts
@@ -2,14 +2,22 @@ import index from "./web/index.html";
|
|||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { normalizeAsin } from "./asin.ts";
|
import { normalizeAsin } from "./asin.ts";
|
||||||
import { db, client } from "./db/index.ts";
|
import { db, client } from "./db/index.ts";
|
||||||
import { analysisRevisions } from "./db/schema.ts";
|
import {
|
||||||
|
analysisRevisions,
|
||||||
|
productDistributorResearch,
|
||||||
|
} from "./db/schema.ts";
|
||||||
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
||||||
|
import { config } from "./config.ts";
|
||||||
import {
|
import {
|
||||||
fetchKeepaDataBatch,
|
fetchKeepaDataBatch,
|
||||||
lookupKeepaUpcs,
|
lookupKeepaUpcs,
|
||||||
mapUpcsToAsins,
|
mapUpcsToAsins,
|
||||||
} from "./integrations/keepa.ts";
|
} from "./integrations/keepa.ts";
|
||||||
import { analyzeProducts } from "./integrations/llm.ts";
|
import { analyzeProducts } from "./integrations/llm.ts";
|
||||||
|
import {
|
||||||
|
searchAsinOffers,
|
||||||
|
type SearxngOfferSearchResult,
|
||||||
|
} from "./integrations/searxng.ts";
|
||||||
import {
|
import {
|
||||||
fetchSellabilityBatch,
|
fetchSellabilityBatch,
|
||||||
fetchSpApiPricingAndFees,
|
fetchSpApiPricingAndFees,
|
||||||
@@ -492,10 +500,37 @@ async function getProduct(asin: string) {
|
|||||||
ORDER BY revision.analyzed_at DESC`,
|
ORDER BY revision.analyzed_at DESC`,
|
||||||
[asin],
|
[asin],
|
||||||
);
|
);
|
||||||
return { product, observations, analyses };
|
const distributorResearchRows = await pgAll<Record<string, unknown>>(
|
||||||
|
`SELECT id, run_item_id, inventory_item_id, provider, model, status, distributors_json, raw_response, created_at
|
||||||
|
FROM product_distributor_research
|
||||||
|
WHERE product_asin = ?
|
||||||
|
ORDER BY created_at DESC, id DESC`,
|
||||||
|
[asin],
|
||||||
|
);
|
||||||
|
const distributorResearch = distributorResearchRows.map((row) => {
|
||||||
|
const distributors = (() => {
|
||||||
|
try {
|
||||||
|
return normalizeDistributorCandidates(JSON.parse(String(row.distributors_json ?? "[]")));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
run_item_id: row.run_item_id == null ? null : Number(row.run_item_id),
|
||||||
|
inventory_item_id: row.inventory_item_id == null ? null : Number(row.inventory_item_id),
|
||||||
|
provider: String(row.provider ?? ""),
|
||||||
|
model: String(row.model ?? ""),
|
||||||
|
status: String(row.status ?? ""),
|
||||||
|
created_at: String(row.created_at ?? ""),
|
||||||
|
distributors,
|
||||||
|
raw_response: row.raw_response == null ? null : String(row.raw_response),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { product, observations, analyses, distributorResearch };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reanalyzeRunItem(itemId: number) {
|
async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||||
const row = await pgGet<Record<string, any>>(
|
const row = await pgGet<Record<string, any>>(
|
||||||
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
|
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
|
||||||
COALESCE(p.name, si.supplied_name, ri.product_asin) AS product_name,
|
COALESCE(p.name, si.supplied_name, ri.product_asin) AS product_name,
|
||||||
@@ -505,7 +540,8 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
si.fba_net_sheet, si.gross_profit_dollar, si.gross_profit_pct,
|
si.fba_net_sheet, si.gross_profit_dollar, si.gross_profit_pct,
|
||||||
si.net_profit_sheet, si.roi_sheet, si.moq, si.moq_cost,
|
si.net_profit_sheet, si.roi_sheet, si.moq, si.moq_cost,
|
||||||
si.qty_available, si.supplier, si.source_url, si.asin_link,
|
si.qty_available, si.supplier, si.source_url, si.asin_link,
|
||||||
si.promo_coupon_code, si.notes, si.lead_date
|
si.promo_coupon_code, si.notes, si.lead_date,
|
||||||
|
ri.source_inventory_item_id
|
||||||
FROM run_items ri JOIN runs r ON r.id = ri.run_id
|
FROM run_items ri JOIN runs r ON r.id = ri.run_id
|
||||||
LEFT JOIN products p ON p.asin = ri.product_asin
|
LEFT JOIN products p ON p.asin = ri.product_asin
|
||||||
LEFT JOIN sourcing_inputs si ON si.run_item_id = ri.id
|
LEFT JOIN sourcing_inputs si ON si.run_item_id = ri.id
|
||||||
@@ -556,7 +592,7 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const verdict =
|
const verdict =
|
||||||
(await analyzeProducts([enriched], { useClaude: USE_CLAUDE }))[0] ?? {
|
(await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
verdict: "SKIP" as const,
|
verdict: "SKIP" as const,
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
@@ -577,6 +613,192 @@ async function reanalyzeRunItem(itemId: number) {
|
|||||||
return { itemId, runId: row.run_id, asin: row.asin, fetchedAt: enriched.fetchedAt };
|
return { itemId, runId: row.run_id, asin: row.asin, fetchedAt: enriched.fetchedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DistributorCandidate = {
|
||||||
|
name: string;
|
||||||
|
website: string;
|
||||||
|
rationale: string;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampDistributorConfidence(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[] {
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
return payload
|
||||||
|
.filter((item): item is Record<string, unknown> => item != null && typeof item === "object")
|
||||||
|
.map((item) => ({
|
||||||
|
name: String(item.name ?? "").trim(),
|
||||||
|
website: String(item.website ?? "").trim(),
|
||||||
|
rationale: String(item.rationale ?? "").trim(),
|
||||||
|
confidence: clampDistributorConfidence(item.confidence),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonArrayFromText(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
const candidate = fence ? fence[1]?.trim() ?? "" : trimmed;
|
||||||
|
const start = candidate.indexOf("[");
|
||||||
|
const end = candidate.lastIndexOf("]");
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
return candidate.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestClaudeDistributorCandidates(context: Record<string, unknown>) {
|
||||||
|
if (!config.anthropicApiKey) {
|
||||||
|
throw new Error("Missing required env var: ANTHROPIC_API_KEY");
|
||||||
|
}
|
||||||
|
const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
|
||||||
|
const system = "You identify authorized wholesale distributors for products. Return only JSON.";
|
||||||
|
const prompt = [
|
||||||
|
"Given this Amazon product context, identify up to 5 likely authorized U.S. wholesale distributors.",
|
||||||
|
"Prioritize official brand channels and reputable distributors.",
|
||||||
|
"Return only a raw JSON array with objects:",
|
||||||
|
'[{"name":"...","website":"https://...","rationale":"...","confidence":0-100}]',
|
||||||
|
JSON.stringify(context, null, 2),
|
||||||
|
].join("\n\n");
|
||||||
|
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": config.anthropicApiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
system,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 1800,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const raw = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Claude API error ${response.status}: ${raw.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
let contentText = "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { content?: Array<{ type?: string; text?: string }> };
|
||||||
|
contentText = (parsed.content ?? [])
|
||||||
|
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||||
|
.map((block) => block.text ?? "")
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
contentText = raw;
|
||||||
|
}
|
||||||
|
const arrayText = extractJsonArrayFromText(contentText);
|
||||||
|
let candidates: DistributorCandidate[] = [];
|
||||||
|
try {
|
||||||
|
candidates = normalizeDistributorCandidates(JSON.parse(arrayText));
|
||||||
|
} catch {
|
||||||
|
candidates = [];
|
||||||
|
}
|
||||||
|
return { model, rawResponse: contentText, candidates };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDistributorsForStalkerProduct(runItemId: number) {
|
||||||
|
const row = await pgGet<Record<string, any>>(
|
||||||
|
`SELECT ri.id AS run_item_id, ri.product_asin AS asin, ri.source_inventory_item_id,
|
||||||
|
p.name AS product_title, p.brand, p.category,
|
||||||
|
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
||||||
|
observation.monthly_sold, observation.seller_count, observation.amazon_is_seller,
|
||||||
|
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
||||||
|
latest_analysis.decision AS verdict, latest_analysis.confidence, latest_analysis.reasoning,
|
||||||
|
seller.seller_id, seller.seller_name, seller.rating, seller.rating_count
|
||||||
|
FROM run_items ri
|
||||||
|
JOIN products p ON p.asin = ri.product_asin
|
||||||
|
LEFT JOIN product_observations observation ON observation.id = (
|
||||||
|
SELECT obs.id
|
||||||
|
FROM product_observations obs
|
||||||
|
WHERE obs.product_asin = ri.product_asin
|
||||||
|
ORDER BY obs.fetched_at DESC, obs.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT revision.decision, revision.confidence, revision.reasoning
|
||||||
|
FROM analysis_revisions revision
|
||||||
|
WHERE revision.run_item_id = ri.id
|
||||||
|
ORDER BY revision.analyzed_at DESC, revision.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_analysis ON TRUE
|
||||||
|
LEFT JOIN stalker_inventory_items si ON si.id = ri.source_inventory_item_id
|
||||||
|
LEFT JOIN sellers seller ON seller.seller_id = si.seller_id
|
||||||
|
WHERE ri.id = ?`,
|
||||||
|
[runItemId],
|
||||||
|
);
|
||||||
|
if (!row?.asin) {
|
||||||
|
throw new Error("Stalker product item not found");
|
||||||
|
}
|
||||||
|
const offerResults = await searchAsinOffers(row.asin, {
|
||||||
|
maxResults: 12,
|
||||||
|
includeUnmatchedAsinResults: true,
|
||||||
|
}).catch(() => [] as SearxngOfferSearchResult[]);
|
||||||
|
const promptContext = {
|
||||||
|
asin: row.asin,
|
||||||
|
productTitle: row.product_title ?? null,
|
||||||
|
brand: row.brand ?? null,
|
||||||
|
category: row.category ?? null,
|
||||||
|
metrics: {
|
||||||
|
currentPrice: row.current_price ?? null,
|
||||||
|
avgPrice90d: row.avg_price_90d ?? null,
|
||||||
|
salesRank: row.sales_rank ?? null,
|
||||||
|
monthlySold: row.monthly_sold ?? null,
|
||||||
|
sellerCount: row.seller_count ?? null,
|
||||||
|
amazonIsSeller: row.amazon_is_seller ?? null,
|
||||||
|
canSell: row.can_sell ?? null,
|
||||||
|
sellabilityStatus: row.sellability_status ?? null,
|
||||||
|
sellabilityReason: row.sellability_reason ?? null,
|
||||||
|
verdict: row.verdict ?? null,
|
||||||
|
confidence: row.confidence ?? null,
|
||||||
|
reasoning: row.reasoning ?? null,
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
sellerId: row.seller_id ?? null,
|
||||||
|
sellerName: row.seller_name ?? null,
|
||||||
|
rating: row.rating ?? null,
|
||||||
|
ratingCount: row.rating_count ?? null,
|
||||||
|
},
|
||||||
|
offerResearch: offerResults.map((result) => ({
|
||||||
|
title: result.title,
|
||||||
|
url: result.url,
|
||||||
|
domain: result.domain,
|
||||||
|
snippet: result.snippet,
|
||||||
|
score: result.score,
|
||||||
|
rank: result.rank,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const claude = await requestClaudeDistributorCandidates(promptContext);
|
||||||
|
const [saved] = await db
|
||||||
|
.insert(productDistributorResearch)
|
||||||
|
.values({
|
||||||
|
productAsin: row.asin,
|
||||||
|
runItemId: runItemId,
|
||||||
|
inventoryItemId: row.source_inventory_item_id ?? null,
|
||||||
|
provider: "claude",
|
||||||
|
model: claude.model,
|
||||||
|
status: claude.candidates.length ? "completed" : "empty",
|
||||||
|
queryContextJson: JSON.stringify(promptContext),
|
||||||
|
distributorsJson: JSON.stringify(claude.candidates),
|
||||||
|
rawResponse: claude.rawResponse,
|
||||||
|
})
|
||||||
|
.returning({ id: productDistributorResearch.id, createdAt: productDistributorResearch.createdAt });
|
||||||
|
return {
|
||||||
|
asin: row.asin,
|
||||||
|
runItemId: runItemId,
|
||||||
|
researchId: saved?.id ?? null,
|
||||||
|
createdAt: saved?.createdAt ?? null,
|
||||||
|
distributors: claude.candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
||||||
const conditions = ["r.type = 'stalker'"];
|
const conditions = ["r.type = 'stalker'"];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
@@ -710,8 +932,9 @@ async function getStalkerResults(filters: URLSearchParams) {
|
|||||||
|
|
||||||
function stalkerProductSql(where: string) {
|
function stalkerProductSql(where: string) {
|
||||||
return `SELECT r.id AS "runId", r.started_at, seller.seller_id, seller.seller_name,
|
return `SELECT r.id AS "runId", r.started_at, seller.seller_id, seller.seller_name,
|
||||||
seller.rating, seller.rating_count, inventory.product_asin AS asin,
|
seller.rating, seller.rating_count, inventory.id AS inventory_item_id, inventory.product_asin AS asin,
|
||||||
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
observation.can_sell, observation.sellability_status, observation.sellability_reason,
|
||||||
|
analysis.run_item_id,
|
||||||
product.name AS product_title, product.brand,
|
product.name AS product_title, product.brand,
|
||||||
CASE WHEN product.category IS NULL THEN NULL ELSE json_build_array(product.category)::text END AS category_tree,
|
CASE WHEN product.category IS NULL THEN NULL ELSE json_build_array(product.category)::text END AS category_tree,
|
||||||
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
observation.current_price, observation.avg_price_90d, observation.sales_rank,
|
||||||
@@ -724,7 +947,7 @@ function stalkerProductSql(where: string) {
|
|||||||
JOIN products product ON product.asin = inventory.product_asin
|
JOIN products product ON product.asin = inventory.product_asin
|
||||||
JOIN product_observations observation ON observation.id = inventory.observation_id
|
JOIN product_observations observation ON observation.id = inventory.observation_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT revision.decision, revision.confidence, revision.reasoning
|
SELECT item.id AS run_item_id, revision.decision, revision.confidence, revision.reasoning
|
||||||
FROM run_items item
|
FROM run_items item
|
||||||
JOIN analysis_revisions revision ON revision.run_item_id = item.id
|
JOIN analysis_revisions revision ON revision.run_item_id = item.id
|
||||||
WHERE item.source_inventory_item_id = inventory.id
|
WHERE item.source_inventory_item_id = inventory.id
|
||||||
@@ -884,6 +1107,30 @@ const server = Bun.serve({
|
|||||||
"/api/stalker/results": async (req) => json(await getStalkerResults(new URL(req.url).searchParams)),
|
"/api/stalker/results": async (req) => json(await getStalkerResults(new URL(req.url).searchParams)),
|
||||||
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
|
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
|
||||||
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(new URL(req.url).searchParams),
|
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(new URL(req.url).searchParams),
|
||||||
|
"/api/stalker/products/:runItemId/reanalyze": async (req) => {
|
||||||
|
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||||
|
const runItemId = Number(req.params.runItemId);
|
||||||
|
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
|
||||||
|
const provider = new URL(req.url).searchParams.get("provider")?.trim().toLowerCase();
|
||||||
|
const useClaude = provider === "claude";
|
||||||
|
try {
|
||||||
|
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: message }, message === "Run item not found" ? 404 : 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stalker/products/:runItemId/distributors": async (req) => {
|
||||||
|
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||||
|
const runItemId = Number(req.params.runItemId);
|
||||||
|
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
|
||||||
|
try {
|
||||||
|
return json(await findDistributorsForStalkerProduct(runItemId));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: message }, message === "Stalker product item not found" ? 404 : 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/stalker/purge": async (req) =>
|
"/api/stalker/purge": async (req) =>
|
||||||
req.method === "DELETE" || req.method === "POST"
|
req.method === "DELETE" || req.method === "POST"
|
||||||
? json(await purgeStalkerData())
|
? json(await purgeStalkerData())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db } from "../db/index.ts";
|
import { client, db } from "../db/index.ts";
|
||||||
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
|
import { persistLlmResults, refreshRunStats } from "../db/persistence.ts";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { normalizeAsin } from "../asin.ts";
|
import { normalizeAsin } from "../asin.ts";
|
||||||
@@ -261,8 +261,15 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main()
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
.catch((error) => {
|
||||||
process.exit(1);
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
});
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
try {
|
||||||
|
await client.end({ timeout: 5 });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ type StalkerProductItem = {
|
|||||||
seller_name: string | null;
|
seller_name: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
rating_count: number | null;
|
rating_count: number | null;
|
||||||
|
inventory_item_id: number;
|
||||||
|
run_item_id: number | null;
|
||||||
asin: string;
|
asin: string;
|
||||||
can_sell: number;
|
can_sell: number;
|
||||||
sellability_status: string;
|
sellability_status: string;
|
||||||
@@ -216,6 +218,22 @@ type ProductHistoryResponse = {
|
|||||||
reasoning: string | null;
|
reasoning: string | null;
|
||||||
analyzed_at: string;
|
analyzed_at: string;
|
||||||
}>;
|
}>;
|
||||||
|
distributorResearch: Array<{
|
||||||
|
id: number;
|
||||||
|
run_item_id: number | null;
|
||||||
|
inventory_item_id: number | null;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
distributors: Array<{
|
||||||
|
name: string;
|
||||||
|
website: string;
|
||||||
|
rationale: string;
|
||||||
|
confidence: number;
|
||||||
|
}>;
|
||||||
|
raw_response: string | null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SortState = {
|
type SortState = {
|
||||||
@@ -1204,6 +1222,11 @@ function StalkerProductsExplorer({
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||||
|
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
|
||||||
|
const [showSellerColumn, setShowSellerColumn] = useState(false);
|
||||||
|
const [showCategoryColumn, setShowCategoryColumn] = useState(false);
|
||||||
|
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
||||||
|
const [findingDistributors, setFindingDistributors] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -1295,6 +1318,64 @@ function StalkerProductsExplorer({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reanalyzeStalkerItem(item: StalkerProductItem) {
|
||||||
|
if (item.run_item_id == null) {
|
||||||
|
window.alert("No analysis item available for this row yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = String(item.run_item_id);
|
||||||
|
if (reanalyzing[key]) return;
|
||||||
|
setReanalyzing((prev) => ({ ...prev, [key]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stalker/products/${item.run_item_id}/reanalyze?provider=claude`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
window.alert(payload?.error ?? "Failed to re-run analysis");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = buildStalkerProductParams(true);
|
||||||
|
const res = await fetch(`/api/stalker/products?${params.toString()}`);
|
||||||
|
const payload = (await res.json()) as StalkerProductsResponse;
|
||||||
|
setResults(payload);
|
||||||
|
} finally {
|
||||||
|
setReanalyzing((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverDistributors(item: StalkerProductItem) {
|
||||||
|
if (item.run_item_id == null) {
|
||||||
|
window.alert("No analysis item available for this row yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = String(item.run_item_id);
|
||||||
|
if (findingDistributors[key]) return;
|
||||||
|
setFindingDistributors((prev) => ({ ...prev, [key]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stalker/products/${item.run_item_id}/distributors`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
window.alert(payload?.error ?? "Failed to find distributors");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setFindingDistributors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1361,6 +1442,11 @@ function StalkerProductsExplorer({
|
|||||||
<button onClick={resetFilters}>Reset filters</button>
|
<button onClick={resetFilters}>Reset filters</button>
|
||||||
<a className="button-link" href={exportHref}>Export XLSX</a>
|
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="button-row" style={{ marginTop: 10 }}>
|
||||||
|
<label><input type="checkbox" checked={showSellerIdColumn} onChange={(e) => setShowSellerIdColumn(e.target.checked)} /> Show Seller ID</label>
|
||||||
|
<label><input type="checkbox" checked={showSellerColumn} onChange={(e) => setShowSellerColumn(e.target.checked)} /> Show Seller</label>
|
||||||
|
<label><input type="checkbox" checked={showCategoryColumn} onChange={(e) => setShowCategoryColumn(e.target.checked)} /> Show Category</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -1371,7 +1457,6 @@ function StalkerProductsExplorer({
|
|||||||
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
||||||
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_title"))}>Product</button></th>
|
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_title"))}>Product</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
||||||
<th>Category</th>
|
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
|
||||||
@@ -1380,17 +1465,19 @@ function StalkerProductsExplorer({
|
|||||||
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
|
{showSellerIdColumn ? <th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th> : null}
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
|
{showSellerColumn ? <th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th> : null}
|
||||||
|
{showCategoryColumn ? <th>Category</th> : null}
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
||||||
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={18}>Loading...</td></tr>
|
<tr><td colSpan={19}>Loading...</td></tr>
|
||||||
) : results?.items.length ? (
|
) : results?.items.length ? (
|
||||||
results.items.map((item) => {
|
results.items.map((item) => {
|
||||||
const categories = parseStringArrayJson(item.category_tree);
|
const categories = parseStringArrayJson(item.category_tree);
|
||||||
@@ -1399,7 +1486,6 @@ function StalkerProductsExplorer({
|
|||||||
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||||
<td className="product-col" title={item.product_title || undefined}>{item.product_title || "-"}</td>
|
<td className="product-col" title={item.product_title || undefined}>{item.product_title || "-"}</td>
|
||||||
<td>{item.brand || "-"}</td>
|
<td>{item.brand || "-"}</td>
|
||||||
<td>{categories.at(-1) || "-"}</td>
|
|
||||||
<td>{formatNumber(item.monthly_sold)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
<td>{formatNumber(item.seller_count)}</td>
|
<td>{formatNumber(item.seller_count)}</td>
|
||||||
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
||||||
@@ -1408,17 +1494,34 @@ function StalkerProductsExplorer({
|
|||||||
<td>{formatCurrency(item.avg_price_90d)}</td>
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
||||||
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||||
<td title={item.reasoning || undefined}>{formatNumber(item.confidence)}</td>
|
<td title={item.reasoning || undefined}>{formatNumber(item.confidence)}</td>
|
||||||
<td>{item.seller_id}</td>
|
{showSellerIdColumn ? <td>{item.seller_id}</td> : null}
|
||||||
<td>{item.seller_name || "-"}</td>
|
{showSellerColumn ? <td>{item.seller_name || "-"}</td> : null}
|
||||||
|
{showCategoryColumn ? <td>{categories.at(-1) || "-"}</td> : null}
|
||||||
<td>{formatNumber(item.rating_count)}</td>
|
<td>{formatNumber(item.rating_count)}</td>
|
||||||
<td><span className="badge badge-ok">{item.sellability_status}</span></td>
|
<td><span className="badge badge-ok">{item.sellability_status}</span></td>
|
||||||
<td>{item.runId}</td>
|
<td>{item.runId}</td>
|
||||||
<td>{formatDate(item.last_seen_at)}</td>
|
<td>{formatDate(item.last_seen_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="stalker-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => reanalyzeStalkerItem(item)}
|
||||||
|
disabled={item.run_item_id == null || reanalyzing[String(item.run_item_id)]}
|
||||||
|
>
|
||||||
|
{item.run_item_id != null && reanalyzing[String(item.run_item_id)] ? "Re-running..." : "Re-run analysis"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => discoverDistributors(item)}
|
||||||
|
disabled={item.run_item_id == null || findingDistributors[String(item.run_item_id)]}
|
||||||
|
>
|
||||||
|
{item.run_item_id != null && findingDistributors[String(item.run_item_id)] ? "Finding..." : "Find distributors"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr><td colSpan={18}>No sellable Stalker products found</td></tr>
|
<tr><td colSpan={19}>No sellable Stalker products found</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1488,6 +1591,34 @@ function ProductDetails({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Distributor Research</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Created</th><th>Provider</th><th>Model</th><th>Status</th><th>Distributors</th><th>Run Item</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.distributorResearch.length ? data.distributorResearch.map((entry) => (
|
||||||
|
<tr key={entry.id}>
|
||||||
|
<td>{formatDate(entry.created_at)}</td>
|
||||||
|
<td>{entry.provider}</td>
|
||||||
|
<td>{entry.model}</td>
|
||||||
|
<td>{entry.status}</td>
|
||||||
|
<td className="reason-col">
|
||||||
|
{entry.distributors.length ? entry.distributors.map((distributor, idx) => (
|
||||||
|
<div key={`${entry.id}-${idx}`} style={{ marginBottom: 8 }}>
|
||||||
|
<div><strong>{distributor.name}</strong> ({formatNumber(distributor.confidence)}%)</div>
|
||||||
|
<div><a href={distributor.website} target="_blank" rel="noreferrer">{distributor.website}</a></div>
|
||||||
|
<div>{distributor.rationale || "-"}</div>
|
||||||
|
</div>
|
||||||
|
)) : "-"}
|
||||||
|
</td>
|
||||||
|
<td>{entry.run_item_id ?? "-"}</td>
|
||||||
|
</tr>
|
||||||
|
)) : <tr><td colSpan={6}>No distributor research yet</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Observations</h3>
|
<h3>Observations</h3>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ td {
|
|||||||
min-width: 1320px;
|
min-width: 1320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stalker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: #fafafb;
|
background: #fafafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user