← back to Govarbitrage

src/agents/appraiser.ts

86 lines

import { prisma } from "@/lib/db";
import { runAgent, type AgentRunResult } from "./framework";
import { runResearch } from "@/pipeline/research";

// The Appraiser re-researches poorly-identified listings WITH the local AI
// (Ollama qwen3:14b) so valuations become item-specific instead of the
// keyword-heuristic placeholder (the "$900 retail / 214% ROI for everything"
// artifact). Sequential (concurrency 1) — one local LLM, ~30-60s per listing.

const num = (d: unknown): number | null => (d == null ? null : Number(d));

/**
 * Pick up to `limit` ACTIVE, still-open listings ordered by soonest close
 * where identification is weak (manufacturer/model missing, or the last
 * research pass was heuristic-only / low confidence), and re-run research
 * with useAI + writeIdentity.
 */
export async function runAppraiser(limit = 25): Promise<AgentRunResult> {
  return runAgent("appraiser", async (ctx) => {
    const now = new Date();
    const candidates = await prisma.listing.findMany({
      where: {
        listingStatus: "ACTIVE",
        closingAt: { gt: now },
        OR: [
          { manufacturer: null },
          { model: null },
          { identifiedBy: null },
          { identifiedBy: "heuristic" },
          { research: { is: { confidenceScore: { lt: 40 } } } },
        ],
      },
      orderBy: { closingAt: "asc" },
      take: limit,
      include: { research: true },
    });

    let aiIdentified = 0;
    let fallbacks = 0;

    for (const l of candidates) {
      const beforeRetail = num(l.research?.newRetail);
      const beforeConfidence = num(l.research?.confidenceScore);

      const result = await runResearch(l.id, { useAI: true, writeIdentity: true });
      ctx.count();

      const after = await prisma.research.findUnique({ where: { listingId: l.id } });
      const afterRetail = num(after?.newRetail);
      const afterConfidence = num(after?.confidenceScore);

      const fmt = (v: number | null, pct = false) =>
        v == null ? "—" : pct ? `${Math.round(v)}/100` : `$${Math.round(v)}`;
      const delta =
        `newRetail ${fmt(beforeRetail)} → ${fmt(afterRetail)}, ` +
        `confidence ${fmt(beforeConfidence, true)} → ${fmt(afterConfidence, true)}`;

      if (result.identifiedBy === "heuristic") {
        fallbacks++;
        await ctx.finding({
          kind: "AI_FALLBACK",
          severity: "WARN",
          listingId: l.id,
          listingTitle: l.title,
          title: `Ollama returned nothing — heuristic fallback kept for "${l.title.slice(0, 80)}"`,
          detail: `${delta}. Valuation remains keyword-heuristic; re-run when the local model is reachable.`,
        });
      } else {
        aiIdentified++;
        await ctx.finding({
          kind: "REAPPRAISED",
          severity: "INFO",
          listingId: l.id,
          listingTitle: l.title,
          title: `Re-appraised via ${result.identifiedBy}: ${delta}`,
          detail:
            `${l.title}. Net $${Math.round(result.expectedNetProfit)}, ` +
            `ROI ${Math.round(result.roi * 100)}%, overall score ${Math.round(result.overallScore)}.`,
        });
      }
    }

    return `${candidates.length} listings appraised — ${aiIdentified} AI-identified, ${fallbacks} heuristic fallbacks`;
  });
}