← back to Discontinued Agent
lib/parse.js
191 lines
// Thread parsing + gating logic for the discontinued-agent.
//
// A follow-up thread contains:
// - DW OUTBOUND message(s) (from info@designerwallcoverings.com) that list the
// requested SKUs under a "Manufacturer Number" section.
// - VENDOR REPLY message(s) that may state a product is discontinued.
//
// We extract the candidate mfr#(s) from the DW outbound, read the vendor reply
// text, and classify each thread into: COMMIT (high-confidence explicit
// discontinuation) / QUEUE (ambiguous/sensitive) / SKIP (nothing actionable).
import crypto from 'node:crypto';
const DW_FROM_RE = /info@designerwallcoverings\.com/i;
// Strip HTML to readable-ish plain text.
export function htmlToText(html = '') {
return String(html)
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|li|h[1-6]|table)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/ /gi, ' ')
.replace(/&/gi, '&')
.replace(/</gi, '<')
.replace(/>/gi, '>')
.replace(/'|’|‘/gi, "'")
.replace(/"|“|”/gi, '"')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function isDwOutbound(msg) {
return DW_FROM_RE.test(msg.from || '');
}
// Mfr# token pattern — vendor style numbers like CM104-2180, TCO08, 5008040,
// W3612-11, T75461. Alphanumerics with optional dashes/slashes, length >=4,
// containing at least one digit. Deliberately permissive; the FileMaker match
// re-validates by finding the row.
const MFR_TOKEN_RE = /\b([A-Z0-9]{1,}(?:[-/. ][A-Z0-9]{1,}){0,3})\b/g;
// The DW outbound footer is a fixed block — its tokens must never be treated
// as mfr#s. (Address line, city/state/zip, toll-free number.)
const FOOTER_BLOCKLIST = [
/^15442$/, // street number
/^CA\s*9\d{4}$/i, // CA + zip
/^9\d{4}$/, // bare CA zip
/^1?[-.\s]?888[-.\s]?373[-.\s]?4564$/, // DW toll-free
/^373[-.\s]?4564$/,
/^102$/, // suite #
];
function looksLikeMfr(tok) {
const t = tok.trim();
if (t.length < 4 || t.length > 24) return false;
if (!/[0-9]/.test(t)) return false; // must contain a digit
if (!/[A-Z0-9]/i.test(t)) return false;
// reject pure prose numbers / dates / phone-ish / money / time-of-day
if (/^\d{1,2}[/.]\d{1,2}([/.]\d{2,4})?$/.test(t)) return false; // dates
if (/^\$?\d+[.,]\d{2}$/.test(t)) return false; // money
if (/\b(?:AM|PM)\b/i.test(t)) return false; // time fragments like "22 AM"
if (/^(10|days|over|2026|2025|2024|2023)$/i.test(t)) return false;
if (FOOTER_BLOCKLIST.some((re) => re.test(t))) return false;
return true;
}
// Grab the LAST mfr-looking token on a line — the DW outbound lists each item
// as "<Pattern Name> <MFR#>" (e.g. "Shooting Star CM104-2180").
function lastMfrTokenOnLine(line) {
let last = null;
let mm;
const re = new RegExp(MFR_TOKEN_RE.source, 'g');
while ((mm = re.exec(line)) !== null) {
const tok = mm[1].trim();
if (looksLikeMfr(tok)) last = tok;
}
return last;
}
// Extract mfr#(s) from a DW outbound message.
//
// The outbound lists them in a block delimited like:
// Manufacturer Number
// _________________
// Shooting Star CM104-2180
// Shooting Star CM104-2170
// _________________
// Ship to the address below. <- footer (address/phone) starts here
//
// So we scope extraction to the lines BETWEEN the "Manufacturer Number" heading
// and the NEXT underscore/asterisk divider, taking the trailing mfr# per line.
// This deliberately excludes the address/phone footer that follows.
export function extractMfrNumbers(outboundText) {
const text = outboundText || '';
const found = new Set();
const lines = text.split(/\n/);
const headIdx = lines.findIndex((l) => /manufacturer\s*(?:number|#|no\.?)/i.test(l));
if (headIdx >= 0) {
for (let i = headIdx + 1; i < lines.length; i++) {
const raw = lines[i];
const line = raw.replace(/^[>\s]+/, '').trim();
if (!line) continue;
// Divider line (all underscores / dashes) — skip until we hit content.
if (/^[_\-–—\s]{3,}$/.test(line)) {
// A divider AFTER we've already collected items ends the block.
if (found.size > 0) break;
continue;
}
// A "Manufacturer Number" label line on its own — skip.
if (/^manufacturer\s*(?:number|#|no\.?)\s*:?\s*$/i.test(line)) continue;
// Explicit "Manufacturer Number: XXX" form.
const labeled = line.match(/manufacturer\s*(?:number|#|no\.?)\s*[:\-]?\s*(.+)$/i);
const target = labeled ? labeled[1] : line;
const tok = lastMfrTokenOnLine(target);
if (tok) {
found.add(tok.toUpperCase());
} else if (found.size > 0) {
// Once we've started collecting, a non-mfr content line (e.g. "Ship to
// the address below.") marks the end of the item block.
break;
}
}
}
// NO whole-body fallback: if the structured "Manufacturer Number" block
// yielded nothing, we return empty so the thread is QUEUED as "no mfr#"
// rather than risking a false match from prose/footer tokens.
return [...found];
}
// --- Gating classification ---
// HIGH-CONFIDENCE explicit discontinuation phrases.
const DISCO_PHRASES = [
/\bdiscontinued\b/i,
/\bdiscontinu(?:e|ing)\b/i,
/\bdisco'?d?\b/i, // vendor shorthand "disco" / "disco'd"
/\bno longer available\b/i,
/\bno longer offer(?:ed|ing)?\b/i,
/\bno longer carr(?:y|ied|ies)\b/i,
/\bno longer (?:be )?(?:mak\w+|produc\w+|manufactur\w+)\b/i,
/\bhas been discontinued\b/i,
/\bcan\s?not (?:be )?order(?:ed)?[^.]{0,40}discontinued/i,
/\bdiscontinued[^.]{0,40}can\s?not (?:be )?order/i,
// "we are not going to make it again" family — vendor won't remake/reproduce.
/\bnot (?:going to |gonna )?(?:be )?(?:re)?(?:mak\w+|produc\w+|manufactur\w+)[^.]{0,30}\bagain\b/i,
/\b(?:won'?t|will not) be (?:re)?(?:mak\w+|produc\w+|manufactur\w+)/i,
/\b(?:won'?t|will not) (?:be )?(?:re)?(?:made|produced|manufactured)(?:\s+again)?\b/i,
/\bnot (?:be )?(?:re)?(?:made|produced|manufactured) again\b/i,
];
// QUEUE-worthy (ambiguous / sensitive) phrases — do NOT auto-discontinue.
const QUEUE_PHRASES = [
{ re: /\bno sample(?:s)? available\b/i, why: 'no sample available (not a discontinuation)' },
{ re: /\bout of stock\b/i, why: 'out of stock (not a discontinuation)' },
{ re: /\bback\s?order(?:ed)?\b/i, why: 'backordered / no restock date' },
{ re: /\bno restock\b/i, why: 'no restock date' },
{ re: /\bended (?:our|the) (?:retailer|vendor|reseller|distributor) relationship\b/i, why: 'vendor ended retailer/vendor relationship (relationship-ended)' },
{ re: /\bwithout brand attribution\b/i, why: 'brand-attribution complaint' },
{ re: /\bbrand attribution\b/i, why: 'brand-attribution concern' },
{ re: /\bcease and desist\b/i, why: 'legal / cease-and-desist' },
{ re: /\bnot (?:an )?authorized (?:dealer|retailer)\b/i, why: 'authorization dispute' },
{ re: /\bremove (?:our|the) (?:products|styles|patterns|brand)\b/i, why: 'vendor asked to remove listings' },
];
// Classify a vendor reply's text.
export function classifyReply(replyText) {
const text = replyText || '';
// Sensitive/ambiguous flags always take precedence (fail-safe to QUEUE).
const queueHits = QUEUE_PHRASES.filter((q) => q.re.test(text)).map((q) => q.why);
const discoHit = DISCO_PHRASES.find((re) => re.test(text));
if (queueHits.length > 0) {
return { verdict: 'QUEUE', reasons: queueHits, discontinuedPhrase: discoHit ? discoHit.source : null };
}
if (discoHit) {
return { verdict: 'COMMIT', reasons: ['explicit discontinuation'], discontinuedPhrase: discoHit.source };
}
return { verdict: 'SKIP', reasons: ['no discontinuation or sensitive signal'], discontinuedPhrase: null };
}
// Stable hash of the vendor message content (for the processed-state key).
export function hashMsg(str) {
return crypto.createHash('sha256').update(String(str)).digest('hex').slice(0, 16);
}