← 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(/&nbsp;/gi, ' ')
    .replace(/&amp;/gi, '&')
    .replace(/&lt;/gi, '<')
    .replace(/&gt;/gi, '>')
    .replace(/&#39;|&rsquo;|&lsquo;/gi, "'")
    .replace(/&quot;|&ldquo;|&rdquo;/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);
}