← back to Govarbitrage
src/pipeline/research.ts
258 lines
import { prisma } from "@/lib/db";
import { identifyProduct } from "@/lib/ai";
import { estimateDemand } from "@/engines/demand";
import { computeValuation } from "@/engines/valuation";
import { computeCosts } from "@/engines/costs";
import { computeScores } from "@/engines/scoring";
import { estimateFreight } from "@/engines/freight";
import { clamp } from "@/lib/utils";
import type { ConditionKey, SourceKey } from "@/engines/types";
export interface ResearchOptions {
/** Run the local AI identifier (Ollama). Off by default for deterministic seeds. */
useAI?: boolean;
/** Deterministic overrides (used by the seeder to inject realistic anchors). */
anchor?: { newRetail?: number; demandScore?: number };
comparables?: {
kind: "SOLD" | "ACTIVE" | "RETAIL";
title: string;
price: number;
url?: string;
source?: string;
soldAt?: Date;
}[];
/** Persist AI-identified manufacturer/model back onto the listing. */
writeIdentity?: boolean;
}
/** Infer local-pickup requirement from weight + auction terms. */
function inferLocalPickup(weightLbs: number, terms?: string | null): boolean {
const t = (terms || "").toLowerCase();
if (t.includes("pickup only") || t.includes("pick up only") || t.includes("no shipping")) return true;
if (t.includes("shipping available") || t.includes("will ship")) return false;
return weightLbs > 300; // heavy lots are usually pickup-only at gov auctions
}
/**
* Full research run for one listing: identify → value → cost → score → persist.
* Idempotent (upserts), safe to re-run. Returns a compact summary.
*/
export async function runResearch(listingId: string, opts: ResearchOptions = {}) {
const listing = await prisma.listing.findUnique({ where: { id: listingId } });
if (!listing) throw new Error(`Listing ${listingId} not found`);
await prisma.listing.update({
where: { id: listingId },
data: { researchStatus: "IN_PROGRESS" },
});
const text = [listing.title, listing.category, listing.description]
.filter(Boolean)
.join("\n");
const heuristic = estimateDemand(text);
// Identity + anchor resolution: overrides > AI > heuristic.
let manufacturer = listing.manufacturer;
let model = listing.model;
let category = listing.category;
let newRetail = opts.anchor?.newRetail ?? heuristic.retailFloor;
let demandScore = opts.anchor?.demandScore ?? heuristic.demandScore;
let identificationConfidence = 0.45; // heuristic baseline
let identifiedBy = "heuristic";
let identifyRaw: unknown = { heuristic };
if (opts.useAI) {
const { result, model: aiModel } = await identifyProduct({
title: listing.title,
description: listing.description,
category: listing.category,
});
if (result) {
manufacturer = result.manufacturer || manufacturer;
model = result.model || model;
category = result.category || category;
if (typeof result.estimatedNewRetail === "number" && result.estimatedNewRetail > 0) {
// Blend AI estimate with the heuristic floor to avoid wild outliers.
newRetail = Math.max(result.estimatedNewRetail, heuristic.retailFloor * 0.5);
}
if (typeof result.demandScore === "number") demandScore = clamp(result.demandScore, 0, 100);
identificationConfidence = clamp(result.confidence ?? 0.6, 0, 1);
identifiedBy = aiModel;
identifyRaw = result;
}
}
const condition = listing.condition as ConditionKey;
const comps = opts.comparables ?? [];
// --- Valuation ---
const valuation = computeValuation({
newRetail,
condition,
quantity: listing.quantity,
demandScore,
identificationConfidence,
comparableCount: comps.length,
});
// --- Costs (winning bid modeled as the current bid) ---
const weightLbs = listing.weightLbs ?? 0;
const localPickup = inferLocalPickup(weightLbs, listing.auctionTerms);
const fr = estimateFreight(weightLbs);
const costs = computeCosts({
source: listing.source as SourceKey,
winningBid: Number(listing.currentBid),
quantity: listing.quantity,
weightLbs,
condition,
expectedSalePrice: valuation.expectedSalePrice,
daysUntilSold: valuation.daysUntilSold,
localPickup,
});
// --- Scores ---
const scores = computeScores({
roi: costs.roi,
expectedNetProfit: costs.expectedNetProfit,
demandScore,
daysUntilSold: valuation.daysUntilSold,
weightLbs,
condition,
bidCount: listing.bidCount,
localPickup,
hasBuyerLeads: false,
confidenceScore: valuation.confidenceScore,
probabilityOfSale: valuation.probabilityOfSale,
isFreight: fr.isFreight,
});
// --- Persist ---
await prisma.$transaction(async (tx) => {
await tx.research.upsert({
where: { listingId },
create: {
listingId,
...valuationToDb(valuation),
sources: comps.map((c) => ({ name: c.source ?? "comp", url: c.url, kind: c.kind, price: c.price })),
summary: buildSummary(listing.title, valuation, costs, demandScore),
model: identifiedBy,
},
update: {
...valuationToDb(valuation),
summary: buildSummary(listing.title, valuation, costs, demandScore),
model: identifiedBy,
},
});
await tx.costBreakdown.upsert({
where: { listingId },
create: { listingId, ...costsToDb(costs) },
update: { ...costsToDb(costs) },
});
// Replace comparables.
await tx.comparable.deleteMany({ where: { listingId } });
if (comps.length) {
await tx.comparable.createMany({
data: comps.map((c) => ({
listingId,
kind: c.kind,
title: c.title,
price: c.price,
url: c.url,
source: c.source,
soldAt: c.soldAt,
})),
});
}
await tx.score.deleteMany({ where: { listingId } });
await tx.score.createMany({
data: scores.map((s) => ({
listingId,
profile: s.profile,
value: s.value,
arbitrage: s.components.arbitrage,
demand: s.components.demand,
velocity: s.components.velocity,
logistics: s.components.logistics,
condition: s.components.condition,
competition: s.components.competition,
buyer: s.components.buyer,
risk: s.risk,
dropShip: s.dropShip,
explanation: s.explanation,
factors: s.factors,
})),
});
await tx.listing.update({
where: { id: listingId },
data: {
researchStatus: "COMPLETE",
identifiedBy,
identifyRaw: identifyRaw as object,
...(opts.writeIdentity ? { manufacturer, model, category } : {}),
},
});
await tx.listingEvent.create({
data: {
listingId,
type: "RESEARCH_COMPLETED",
message: `Research complete via ${identifiedBy}: est. net ${costs.expectedNetProfit.toFixed(0)}, ROI ${(costs.roi * 100).toFixed(0)}%`,
meta: { model: identifiedBy },
},
});
});
return {
listingId,
expectedNetProfit: costs.expectedNetProfit,
roi: costs.roi,
overallScore: scores.find((s) => s.profile === "OVERALL_OPPORTUNITY")?.value ?? 0,
identifiedBy,
};
}
function valuationToDb(v: ReturnType<typeof computeValuation>) {
return {
newRetail: v.newRetail,
newReplacement: v.newReplacement,
avgRetail: v.avgRetail,
usedSoldPrice: v.usedSoldPrice,
usedAskingPrice: v.usedAskingPrice,
usedLow: v.usedLow,
usedHigh: v.usedHigh,
wholesaleValue: v.wholesaleValue,
liquidationValue: v.liquidationValue,
sellTodayValue: v.sellTodayValue,
value7Day: v.value7Day,
value30Day: v.value30Day,
value90Day: v.value90Day,
expectedSalePrice: v.expectedSalePrice,
probabilityOfSale: v.probabilityOfSale,
daysUntilSold: v.daysUntilSold,
confidenceScore: v.confidenceScore,
};
}
function costsToDb(c: ReturnType<typeof computeCosts>) {
const { assumptions, ...rest } = c;
return { ...rest, assumptions: assumptions as object };
}
function buildSummary(
title: string,
v: ReturnType<typeof computeValuation>,
c: ReturnType<typeof computeCosts>,
demand: number,
): string {
return (
`${title}: est. resale ${v.expectedSalePrice.toFixed(0)} vs new retail ${v.newRetail.toFixed(0)}. ` +
`At the current bid, total-in ${c.totalInvestment.toFixed(0)} → net ${c.expectedNetProfit.toFixed(0)} ` +
`(${(c.roi * 100).toFixed(0)}% ROI). Demand ${demand.toFixed(0)}/100, ~${v.daysUntilSold} days to sell, ` +
`confidence ${v.confidenceScore.toFixed(0)}/100. Recommended max bid ${c.recommendedMaxBid.toFixed(0)}.`
);
}