← back to AbramsOS

lib/recall-matcher.js

115 lines

// Implements the canonical recall-matching pseudocode from docs/SPEC.md.
// Compares an asset (purchase or purchase_item) against a recall_event's
// product_keys; emits a confidence score 0..1 and a status.

const db = require('./db');
const audit = require('./audit');
const { id } = require('./ids');

function fuzzy(a, b) {
  if (!a || !b) return 0;
  const A = String(a).toLowerCase().replace(/[^a-z0-9]/g, '');
  const B = String(b).toLowerCase().replace(/[^a-z0-9]/g, '');
  if (!A || !B) return 0;
  if (A === B) return 1;
  if (A.includes(B) || B.includes(A)) return 0.95;
  // crude similarity: longest common token
  const tokens = (s) => s.match(/[a-z0-9]{3,}/g) || [];
  const tA = new Set(tokens(A));
  const tB = new Set(tokens(B));
  let hits = 0;
  for (const t of tA) if (tB.has(t)) hits += 1;
  if (!tA.size) return 0;
  return Math.min(0.9, hits / tA.size);
}

function bestModelMatch(modelStr, modelList) {
  if (!modelStr || !modelList?.length) return 0;
  let best = 0;
  for (const m of modelList) {
    const s = fuzzy(modelStr, m);
    if (s > best) best = s;
  }
  return best;
}

/**
 * @param asset    { id, gtin, model, brand, serial, category, purchase_date }
 * @param recall   recall_event row with product_keys_jsonb already parsed
 */
function score(asset, recall) {
  let s = 0;
  const pk = recall.product_keys_jsonb || recall.product_keys || {};

  if (asset.gtin && (pk.gtins || []).includes(asset.gtin)) s += 0.45;
  if (asset.gtin && (pk.upcs || []).includes(asset.gtin)) s += 0.45;

  if (asset.model) {
    const ms = bestModelMatch(asset.model, pk.models);
    if (ms > 0.92) s += 0.25;
    else if (ms > 0.6) s += 0.10;
  }

  // Serial range matching is rare for CPSC consumer recalls; skip unless we have explicit ranges
  if (asset.serial && Array.isArray(pk.serial_ranges)) {
    for (const r of pk.serial_ranges) {
      if (r.start && r.end && String(asset.serial) >= r.start && String(asset.serial) <= r.end) {
        s += 0.20; break;
      }
    }
  }

  if (asset.brand && pk.brand && fuzzy(asset.brand, pk.brand) > 0.8) s += 0.05;

  if (asset.purchase_date && recall.published_at && new Date(recall.published_at) >= new Date(asset.purchase_date)) {
    s += 0.05;
  }

  // Medical-device UDI bonus (deferred — medical_asset isn't implemented yet)
  if (asset.category === 'medical_device' && asset.udi_di && pk.udi_di === asset.udi_di) s += 0.40;

  s = Math.min(1, Math.round(s * 1000) / 1000);
  let status;
  if (s >= 0.85) status = 'positive_match';
  else if (s >= 0.60) status = 'needs_user_confirmation';
  else status = 'no_match';
  return { score: s, status };
}

/**
 * Walk all unmatched purchases for a user against a recall.
 * Inserts recall_match rows for any score >= 0.60.
 */
async function matchRecallAgainstUser(recall, userId) {
  const purchases = await db.query(
    `SELECT p.id, p.merchant_name AS brand, p.purchase_date, NULL::text AS gtin, NULL::text AS model, NULL::text AS serial, NULL::text AS category
       FROM purchase p WHERE p.user_id = $1`,
    [userId]
  );
  let inserted = 0;
  for (const a of purchases.rows) {
    const r = score(a, recall);
    if (r.score < 0.60) continue;
    const matchId = id('purchase'); // re-use ulid prefix
    try {
      await db.query(
        `INSERT INTO recall_match (id, recall_id, asset_table, asset_id, user_id, confidence, status, metadata_jsonb)
         VALUES ($1, $2, 'purchase', $3, $4, $5, $6, $7)
         ON CONFLICT (recall_id, asset_table, asset_id) DO NOTHING`,
        [matchId, recall.id, a.id, userId, r.score, r.status === 'positive_match' ? 'pending_review' : 'pending_review', { score: r.score, status: r.status }]
      );
      inserted += 1;
      await audit.log({
        actorType: 'system',
        objectType: 'recall_match',
        objectId: matchId,
        eventType: 'recall_match_created',
        metadata: { recall_id: recall.id, asset_id: a.id, score: r.score, status: r.status },
      });
    } catch (_) { /* dup */ }
  }
  return inserted;
}

module.exports = { score, fuzzy, bestModelMatch, matchRecallAgainstUser };