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