← back to Homesonspec

apps/workers/src/verify.ts

255 lines

import { prisma, type Freshness, type VerificationLabel } from "@homesonspec/database";

/**
 * Stage-3 verification engine — the maintenance layer that runs OVER already
 * published inventory on a schedule. Ingestion (pipeline.ts) gets a home in the
 * door; this keeps the marketplace honest afterward:
 *   • freshness decay        — verified data ages FRESH → AGING → REVIEW → STALE
 *   • source-health auto-pause — repeated failures trip a source to PAUSED
 *   • cross-source dedup      — same address from two sources → keep the best
 *
 * The pure decision functions (computeFreshness, labelRank, pickDedupeWinner)
 * carry no DB and are unit-tested directly; the exported async ops wrap them.
 */

export const HOUR_MS = 3_600_000;

// Default cadence when a home has no registered source.
export const DEFAULT_INTERVALS = { freshHrs: 24, agingHrs: 72, staleHrs: 168 };

/** Pure: freshness from age vs a source's thresholds. Null verify time = must review. */
export function computeFreshness(
  lastVerifiedAt: Date | null,
  freshHrs: number,
  agingHrs: number,
  staleHrs: number,
  now: Date,
): Freshness {
  if (!lastVerifiedAt) return "REVIEW_REQUIRED";
  const ageHrs = (now.getTime() - lastVerifiedAt.getTime()) / HOUR_MS;
  if (ageHrs <= freshHrs) return "FRESH";
  if (ageHrs <= agingHrs) return "AGING";
  if (ageHrs <= staleHrs) return "REVIEW_REQUIRED";
  return "STALE";
}

// Authority ranking — higher wins a dedup tie. DEMONSTRATION never outranks real data.
const LABEL_RANK: Record<VerificationLabel, number> = {
  VERIFIED_FROM_BUILDER: 6,
  BUILDER_WEBSITE: 5,
  BUILDER_SUBMITTED: 4,
  NEEDS_REVERIFICATION: 3,
  NO_LONGER_AVAILABLE: 2,
  DEMONSTRATION: 1,
};
export const labelRank = (l: VerificationLabel) => LABEL_RANK[l] ?? 0;

export interface DedupeCandidate {
  id: string;
  verificationLabel: VerificationLabel;
  lastVerifiedAt: Date | null;
  createdAt: Date;
}

/** Pure: from a set of homes at the same address, choose the one record to keep. */
export function pickDedupeWinner<T extends DedupeCandidate>(group: T[]): T {
  return [...group].sort((a, b) => {
    const r = labelRank(b.verificationLabel) - labelRank(a.verificationLabel);
    if (r !== 0) return r;
    const av = a.lastVerifiedAt?.getTime() ?? 0;
    const bv = b.lastVerifiedAt?.getTime() ?? 0;
    if (bv !== av) return bv - av;
    return b.createdAt.getTime() - a.createdAt.getTime();
  })[0]!;
}

// ── Freshness recompute ────────────────────────────────────────────────────

// Labels that represent an active positive verification — these get demoted to
// NEEDS_REVERIFICATION once a home goes stale.
const REVERIFIABLE = new Set<VerificationLabel>([
  "VERIFIED_FROM_BUILDER",
  "BUILDER_WEBSITE",
  "BUILDER_SUBMITTED",
]);

export async function recomputeFreshness(now = new Date()) {
  const homes = await prisma.inventoryHome.findMany({
    where: { status: "PUBLISHED", isDemo: false },
    select: {
      id: true,
      lastVerifiedAt: true,
      freshness: true,
      verificationLabel: true,
      source: { select: { freshIntervalHours: true, agingIntervalHours: true, staleIntervalHours: true } },
    },
  });

  const byTarget: Record<Freshness, string[]> = {
    FRESH: [], AGING: [], REVIEW_REQUIRED: [], STALE: [], INACTIVE: [],
  };
  const relabel: string[] = [];

  for (const h of homes) {
    const fresh = h.source?.freshIntervalHours ?? DEFAULT_INTERVALS.freshHrs;
    const aging = h.source?.agingIntervalHours ?? DEFAULT_INTERVALS.agingHrs;
    const stale = h.source?.staleIntervalHours ?? DEFAULT_INTERVALS.staleHrs;
    const target = computeFreshness(h.lastVerifiedAt, fresh, aging, stale, now);
    if (target !== h.freshness) byTarget[target].push(h.id);
    if (target === "STALE" && REVERIFIABLE.has(h.verificationLabel)) relabel.push(h.id);
  }

  let changed = 0;
  for (const [target, ids] of Object.entries(byTarget)) {
    if (ids.length === 0) continue;
    await prisma.inventoryHome.updateMany({ where: { id: { in: ids } }, data: { freshness: target as Freshness } });
    changed += ids.length;
  }
  if (relabel.length > 0) {
    await prisma.inventoryHome.updateMany({
      where: { id: { in: relabel } },
      data: { verificationLabel: "NEEDS_REVERIFICATION" },
    });
  }

  return {
    scanned: homes.length,
    changed,
    relabeled: relabel.length,
    toFresh: byTarget.FRESH.length,
    toAging: byTarget.AGING.length,
    toReview: byTarget.REVIEW_REQUIRED.length,
    toStale: byTarget.STALE.length,
  };
}

// ── Source-health auto-pause ────────────────────────────────────────────────

export interface RunOutcome {
  ok: boolean;
  kind?: string;
  pages?: number;
  changed?: number;
  published?: number;
  needsReview?: number;
  rejected?: number;
  errorCount?: number;
  message?: string;
  durationMs?: number;
}

/**
 * Log a run and roll the source's health forward. A success resets the failure
 * streak (and recovers a paused/degraded source); a failure increments it and,
 * once it reaches maxConsecutiveFailures, trips the source to PAUSED + inactive
 * so runPipeline (which checks `active`) refuses to run it until a human clears it.
 */
export async function recordSourceRun(sourceKey: string, outcome: RunOutcome, now = new Date()) {
  const source = await prisma.sourceRegistry.findUniqueOrThrow({ where: { key: sourceKey } });
  await prisma.sourceRun.create({
    data: {
      sourceId: source.id,
      kind: outcome.kind ?? "pipeline",
      ok: outcome.ok,
      pages: outcome.pages ?? 0,
      changed: outcome.changed ?? 0,
      published: outcome.published ?? 0,
      needsReview: outcome.needsReview ?? 0,
      rejected: outcome.rejected ?? 0,
      errorCount: outcome.errorCount ?? 0,
      message: outcome.message ?? null,
      durationMs: outcome.durationMs ?? null,
      startedAt: now,
    },
  });

  const failures = outcome.ok ? 0 : source.consecutiveFailures + 1;
  const tripped = failures >= source.maxConsecutiveFailures;
  const health = outcome.ok
    ? outcome.errorCount && outcome.errorCount > 0
      ? "DEGRADED"
      : "HEALTHY"
    : tripped
      ? "PAUSED"
      : "FAILING";

  await prisma.sourceRegistry.update({
    where: { id: source.id },
    data: {
      lastRunAt: now,
      lastSuccessAt: outcome.ok ? now : source.lastSuccessAt,
      consecutiveFailures: failures,
      health,
      // Auto-pause disables the source. Recovery is deliberate: only a source that
      // was AUTO-paused (health === PAUSED) re-enables on a clean run. A source an
      // operator manually pulled (active=false while health != PAUSED) stays down —
      // a stray successful run must not silently resurrect it.
      active: outcome.ok ? (source.health === "PAUSED" ? true : source.active) : tripped ? false : source.active,
    },
  });

  return { key: sourceKey, health, consecutiveFailures: failures, autoPaused: !outcome.ok && tripped };
}

// ── Refresh scheduling ──────────────────────────────────────────────────────

/** Sources due for a refresh: active, not paused, and past their fresh interval. */
export async function refreshDue(now = new Date()) {
  const sources = await prisma.sourceRegistry.findMany({
    where: { active: true, health: { not: "PAUSED" } },
    select: { key: true, name: true, lastRunAt: true, freshIntervalHours: true },
  });
  return sources.filter((s) => {
    if (!s.lastRunAt) return true;
    const ageHrs = (now.getTime() - s.lastRunAt.getTime()) / HOUR_MS;
    return ageHrs >= s.freshIntervalHours;
  });
}

// ── Cross-source dedup ──────────────────────────────────────────────────────

/**
 * Find published non-demo homes that share a normalized address + zip (the same
 * physical home surfaced by two sources / crawls). Keep the most authoritative
 * record; deactivate the rest. Dry-run by default — pass { apply: true } to write.
 */
export async function dedupePass(opts: { apply?: boolean } = {}) {
  const homes = await prisma.inventoryHome.findMany({
    where: { status: "PUBLISHED", isDemo: false, normalizedAddress: { not: null } },
    select: {
      id: true,
      normalizedAddress: true,
      zip: true,
      verificationLabel: true,
      lastVerifiedAt: true,
      createdAt: true,
    },
  });

  const groups = new Map<string, typeof homes>();
  for (const h of homes) {
    const key = `${h.normalizedAddress}|${h.zip}`;
    const arr = groups.get(key) ?? [];
    arr.push(h);
    groups.set(key, arr);
  }

  const losers: string[] = [];
  let dupGroups = 0;
  for (const group of groups.values()) {
    if (group.length < 2) continue;
    dupGroups += 1;
    const winner = pickDedupeWinner(group);
    for (const h of group) if (h.id !== winner.id) losers.push(h.id);
  }

  if (opts.apply && losers.length > 0) {
    await prisma.inventoryHome.updateMany({
      where: { id: { in: losers } },
      data: { status: "INACTIVE", freshness: "INACTIVE" },
    });
  }

  return { scanned: homes.length, duplicateGroups: dupGroups, duplicates: losers.length, applied: !!opts.apply };
}