← back to AbramsOS

lib/commitment-extractor.js

134 lines

// Service-commitment extractor.
// Tier 1: regex over receipt body for "money-back guarantee", "30-day return policy",
//         "satisfaction guaranteed", refund windows, price-match promises.
// Tier 2: local Ollama LLM fallback for ambiguous phrasings (TODO).
//
// Each extracted commitment is a structured row that pairs to a purchase + source_message.

const COMMITMENT_PATTERNS = [
  {
    type: 'money_back_guarantee',
    re: /(?:(\d{1,3})[-\s]?day\s+)?(?:full\s+)?money[-\s]?back\s+guarantee/i,
    promisedOutcome: 'refund',
    confidence: 0.85,
  },
  {
    type: 'satisfaction_guarantee',
    re: /(?:100%\s+)?satisfaction\s+guarantee(?:d)?(?:\s+within\s+(\d{1,3})\s*days?)?/i,
    promisedOutcome: 'refund',
    confidence: 0.78,
  },
  {
    type: 'returns_window',
    re: /(?:free\s+)?returns?\s+within\s+(\d{1,3})\s*days?/i,
    promisedOutcome: 'refund',
    confidence: 0.80,
  },
  {
    type: 'returns_window',
    re: /(\d{1,3})[-\s]?day\s+returns?(?:\s+policy)?(?!\s+code)/i,
    promisedOutcome: 'refund',
    confidence: 0.78,
  },
  {
    type: 'returns_window',
    re: /return\s+within\s+(\d{1,3})\s*days?(?:\s+of\s+(?:purchase|receipt|delivery))?/i,
    promisedOutcome: 'refund',
    confidence: 0.80,
  },
  {
    type: 'price_match',
    re: /price[-\s]?match(?:\s+guarantee)?/i,
    promisedOutcome: 'credit',
    confidence: 0.72,
  },
  {
    type: 'service_sla',
    re: /(?:guaranteed\s+)?(?:next[-\s]?day|same[-\s]?day|2[-\s]?day)\s+(?:delivery|shipping|service)/i,
    promisedOutcome: 'service_redo',
    confidence: 0.65,
  },
  {
    type: 'returns_window',
    re: /no\s+questions?\s+asked\s+returns?(?:\s+within\s+(\d{1,3})\s*days?)?/i,
    promisedOutcome: 'refund',
    confidence: 0.85,
  },
  {
    type: 'returns_window',
    re: /lifetime\s+(?:return|exchange|guarantee|warranty)/i,
    promisedOutcome: 'refund',
    confidence: 0.85,
    windowDays: 36500, // sentinel: lifetime
  },
];

function extractConditions(haystack, idx, len) {
  // Look ±200 chars around match for "with original receipt", "unworn", "in original packaging", etc.
  const start = Math.max(0, idx - 100);
  const end = Math.min(haystack.length, idx + len + 200);
  const window = haystack.slice(start, end);
  const conditions = [];
  if (/original\s+(?:receipt|packaging|box|tags?)/i.test(window)) conditions.push('original packaging/receipt');
  if (/unworn|unused|unopened/i.test(window)) conditions.push('unused');
  if (/restocking\s+fee/i.test(window)) conditions.push('subject to restocking fee');
  if (/excludes?\s+(?:final\s+sale|clearance)/i.test(window)) conditions.push('excludes final sale');
  if (/proof\s+of\s+purchase/i.test(window)) conditions.push('proof of purchase required');
  return conditions.length ? conditions.join('; ') : null;
}

/**
 * Extract all service commitments from a Gmail summary.
 * Returns an array of commitment objects (possibly empty).
 *
 * Each commitment: {
 *   commitment_type, guarantee_text, promised_outcome,
 *   window_days, conditions, confidence, source: 'heuristic',
 * }
 */
function extract(summary) {
  const text = (summary?.body?.text || '').slice(0, 12_000);
  const html = (summary?.body?.html || '').slice(0, 12_000);
  // Strip HTML tags for the haystack but keep links visible
  const haystack = [
    text,
    html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '),
  ].join('\n');

  const seen = new Set();
  const out = [];
  for (const p of COMMITMENT_PATTERNS) {
    const m = haystack.match(p.re);
    if (!m) continue;
    const guarantee_text = m[0].trim().slice(0, 200);
    const dedupeKey = `${p.type}::${guarantee_text.toLowerCase()}`;
    if (seen.has(dedupeKey)) continue;
    seen.add(dedupeKey);

    const window_days = p.windowDays ?? (m[1] ? parseInt(m[1], 10) : null);
    const conditions = extractConditions(haystack, m.index || 0, m[0].length);
    out.push({
      commitment_type: p.type,
      guarantee_text,
      promised_outcome: p.promisedOutcome,
      window_days,
      conditions,
      confidence: p.confidence,
      source: 'heuristic',
    });
  }
  return out;
}

/**
 * Convert an extracted commitment + a known purchase_date into a refund_window_ends_at.
 */
function computeWindowEnd(purchaseDate, windowDays) {
  if (!purchaseDate || !windowDays) return null;
  const d = new Date(purchaseDate);
  if (isNaN(d.getTime())) return null;
  return new Date(d.getTime() + windowDays * 86400e3);
}

module.exports = { extract, computeWindowEnd, COMMITMENT_PATTERNS };