← 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 };