← back to AbramsOS

lib/claim-strategist.js

186 lines

// Claim Strategist — picks the best next action for an asset + reminder/recall pair.
// Honors AGENTS.md hard rules: drafts only, no send. Caller approves separately.

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

// Deterministic routing by reason_code; LLM only drafts the prose.
const ROUTING_BY_REASON = {
  returns_window_closing: { claim_type: 'refund',         routing: 'merchant',     desired: 'Refund or store credit' },
  warranty_expiry:        { claim_type: 'repair',         routing: 'manufacturer', desired: 'Repair or replacement under warranty' },
  recall_action_due:      { claim_type: 'recall_remedy',  routing: 'manufacturer', desired: 'Free remedy per CPSC notice (refund/repair/replace)' },
};

function buildContext({ reminder, purchase, recall }) {
  const lines = [];
  if (purchase) {
    lines.push(`Purchase: ${purchase.merchant_name}`);
    if (purchase.order_number) lines.push(`Order #: ${purchase.order_number}`);
    lines.push(`Date: ${new Date(purchase.purchase_date).toDateString()}`);
    if (purchase.total_amount) lines.push(`Amount: ${purchase.currency || 'USD'} ${purchase.total_amount}`);
  }
  if (recall) {
    lines.push(`Recall title: ${recall.title || ''}`);
    lines.push(`Hazard: ${recall.hazard || ''}`);
    lines.push(`Remedy offered: ${recall.remedy || ''}`);
    lines.push(`Source: ${recall.url || 'CPSC'}`);
  }
  return lines.filter(Boolean).join('\n');
}

/**
 * Look up active rights_rule_snapshot rows that match this draft. Returns up
 * to 2 strongest matches (ordered by domain priority + amount-threshold fit).
 */
async function pickCitedRules({ reasonCode, routing, minAmount = 0, jurisdiction = 'US', limit = 2 }) {
  // Match: applies_when.reason_codes contains reasonCode AND applies_when.routing contains routing.
  const r = await db.query(
    `SELECT id, citation, source_url, short_title, citation_block, applies_when
       FROM rights_rule_snapshot
      WHERE superseded_at IS NULL
        AND (jurisdiction = $1 OR jurisdiction = 'INTL')
        AND applies_when @> $2::jsonb
      ORDER BY
        (applies_when ? 'min_amount' AND ($3::numeric >= COALESCE((applies_when->>'min_amount')::numeric, 0))) DESC,
        domain ASC
      LIMIT $4`,
    [jurisdiction, JSON.stringify({ reason_codes: [reasonCode], routing: [routing] }), minAmount, limit]
  );
  return r.rows;
}

async function draftLetter({ routing, claimType, contextText, desiredRemedy, citedRules = [], llm = true, opts = {} }) {
  const ruleBlock = (citedRules || [])
    .map(r => r.citation_block)
    .filter(Boolean)
    .join('\n\n');

  const fallback = `To Whom It May Concern,

I am writing about the following:

${contextText}

I am requesting: ${desiredRemedy}.

${ruleBlock ? ruleBlock + '\n\n' : ''}Please respond within 14 business days. I have retained a copy of this letter.

Sincerely,
[your name]`;

  if (!llm) return { subject: `${claimType} request — ${routing}`, body: fallback, source: 'template' };

  const prompt = `Draft a polite, firm consumer claim letter. Output ONLY a JSON object — no prose, no fences.

Schema: { "subject": string, "body": string }

The body must:
- be under 250 words
- use plain English
- reference the specific facts below
- request the desired remedy clearly
- ask for a written response within 14 business days
- close with "Sincerely, [your name]"
- NOT cite specific case law or pretend to be a lawyer

Routing: this letter goes to the ${routing}.
Claim type: ${claimType}.
Desired remedy: ${desiredRemedy}.

${citedRules.length ? 'Cite these rules verbatim near the end of the body (one short paragraph each):\n' + citedRules.map(r => `- ${r.citation_block}`).join('\n') + '\n' : ''}
Facts:
${contextText}`;

  try {
    const j = await ollama.generateJson(prompt, { timeoutMs: opts.timeoutMs || 30_000, model: opts.model });
    return { subject: j.subject || `${claimType} request — ${routing}`, body: j.body || fallback, source: 'llm' };
  } catch (err) {
    return { subject: `${claimType} request — ${routing}`, body: fallback, source: 'template_fallback', llmError: err.message };
  }
}

/**
 * Build a claim_case row from a calendar_reminder.
 * Returns { caseId, claim }.
 */
async function fromReminder(reminderId, userId) {
  const rem = await db.query(`SELECT * FROM calendar_reminder WHERE id = $1 AND user_id = $2`, [reminderId, userId]);
  if (!rem.rows.length) throw new Error('reminder not found');
  const r = rem.rows[0];
  const reasonCfg = ROUTING_BY_REASON[r.reason_code];
  if (!reasonCfg) throw new Error(`no claim routing for reason_code=${r.reason_code}`);

  // Resolve the asset behind the reminder
  let purchase = null, recall = null;
  if (r.owner_table === 'purchase') {
    const p = await db.query(`SELECT * FROM purchase WHERE id = $1`, [r.owner_id]);
    purchase = p.rows[0] || null;
  } else if (r.owner_table === 'recall_match') {
    const m = await db.query(
      `SELECT rm.*, re.title, re.hazard, re.remedy, re.url
         FROM recall_match rm JOIN recall_event re ON re.id = rm.recall_id
        WHERE rm.id = $1`,
      [r.owner_id]
    );
    if (m.rows[0]) {
      recall = { title: m.rows[0].title, hazard: m.rows[0].hazard, remedy: m.rows[0].remedy, url: m.rows[0].url };
      const p = await db.query(`SELECT * FROM purchase WHERE id = $1`, [m.rows[0].asset_id]);
      purchase = p.rows[0] || null;
    }
  }

  const ctx = buildContext({ reminder: r, purchase, recall });

  // Pick 1-2 cited rules from the corpus that match this reason_code + routing
  const citedRules = await pickCitedRules({
    reasonCode: r.reason_code,
    routing: reasonCfg.routing,
    minAmount: purchase?.total_amount || 0,
  });

  const letter = await draftLetter({
    routing: reasonCfg.routing,
    claimType: reasonCfg.claim_type,
    contextText: ctx,
    desiredRemedy: reasonCfg.desired,
    citedRules,
  });

  const caseId = id('purchase'); // reuse ulid prefix
  await db.query(
    `INSERT INTO claim_case (id, user_id, asset_table, asset_id, claim_type, routing, desired_remedy, due_at, draft_subject, draft_body, cited_rules_jsonb, evidence_jsonb, metadata_jsonb)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
    [
      caseId, userId,
      r.owner_table, r.owner_id,
      reasonCfg.claim_type, reasonCfg.routing,
      reasonCfg.desired, r.due_at,
      letter.subject, letter.body,
      JSON.stringify(citedRules.map(rule => ({ id: rule.id, citation: rule.citation, source_url: rule.source_url, short_title: rule.short_title }))),
      JSON.stringify([{ kind: 'reminder', id: reminderId, due_at: r.due_at }]),
      JSON.stringify({ from_reminder: reminderId, draft_source: letter.source, llm_error: letter.llmError || null }),
    ]
  );

  // Queue a "user_required" action: the user must approve before we send anything
  await db.query(
    `INSERT INTO action_queue (id, case_id, action_type, approval_level, payload_jsonb)
     VALUES ($1, $2, 'send_email', 'user_required', $3)`,
    [id('purchase'), caseId, JSON.stringify({ to_role: reasonCfg.routing, subject: letter.subject })]
  );

  await audit.log({
    actorType: 'system',
    objectType: 'claim_case',
    objectId: caseId,
    eventType: 'claim_drafted',
    metadata: { from_reminder: reminderId, routing: reasonCfg.routing, claim_type: reasonCfg.claim_type, source: letter.source },
  });

  return { caseId, claim: { ...letter, claim_type: reasonCfg.claim_type, routing: reasonCfg.routing, desired_remedy: reasonCfg.desired } };
}

module.exports = { fromReminder, draftLetter, buildContext, pickCitedRules, ROUTING_BY_REASON };