← 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)}.`
  );
}