Capture AI quote corrections as snapshot-vs-final diffs

Dealops 2 now records the net changes reps make to AI-generated quotes when those quotes leave the rep’s hands.

Author: @pk675 PR: dealops#6888 Ticket: DEA-7495 Area: Dealops 2 AI quoting State: open Files: 12 Diff: +979 / -0 Base: main

What it adds

A new internal audit dataset: AiQuoteDiff rows capture what changed between the AI-generated quote snapshot and the quote a rep actually submitted, pushed, or exported.

Capture model

The PR avoids edit-by-edit tracking. It freezes the generated input once, then computes a net diff at departure time; an empty diff means the AI quote was accepted as generated.

Runtime shape

Capture runs in a BullMQ platform-event listener, not on the approval/CRM/export request path. Retries are idempotent per (platformEventId, pricingQuoteId).

Testing

Fourteen new Mocha tests cover diff semantics, malformed JSON degradation, storage-schema validation, and event-to-trigger mapping.

AiQuotingRun
PricingQuote
PlatformEvent
aiQuoteDiff listener
AiQuoteDiff
computeAiQuoteDiff

1. Why this exists

Before

Dealops can tell that the AI quoting agent generated a quote, but not what the rep corrected before sending it onward.

  • missingNo structured training signal for price, quantity, term, or product corrections.
  • noisyTracking every edit would record churn, including changes the rep later reverted.
After

Each departure moment records the final delta from the original AI output.

  • signalNet rep correction, measured once the quote leaves the editor.
  • useful empty statechanges: [] means “sent exactly as generated.”
Scope: this is Dealops 2 server-side training/analysis plumbing. There is no customer-facing UI and no new synchronous quote-send work.

2. What changes

Schema
1new table

AiQuoteDiff stores one row per quote departure moment with typed JSON changes.

Audit snapshot
2new AiQuotingRun fields

createdPricingQuoteIds and generatedInputs make multi-offer AI runs findable and diffable.

Worker
3event triggers

APPROVAL_SUBMIT, CRM_PUSH, and ORDER_FORM_EXPORT.

Database additions

Object Change Why it matters
AiQuotingRun Adds createdPricingQuoteIds String[] @default([]) Multi-offer runs can create several quotes; the listener finds the owning run with Prisma has.
AiQuotingRun Adds generatedInputs Json? Frozen quote input snapshot keyed by pricingQuoteId; null for pre-rollout runs.
AiQuoteDiff New table with changes Json, trigger, ids, and timestamps Stores typed diff entries without FK-coupling to quotes, runs, or users.
AiQuoteDiffTrigger New enum: APPROVAL_SUBMIT, CRM_PUSH, ORDER_FORM_EXPORT Labels the moment when the quote left the rep’s hands.
AiQuoteDiff indexes @@unique([platformEventId, pricingQuoteId]), org/time and quote/time indexes Supports retry idempotency, org-scoped extraction, and quote-level inspection.

Meaningful files

finalizeAiQuote.ts
Captures the persisted PricingQuote.input immediately after quote creation and stores it in the audit state by quote id.
audit.ts + ai-quoting-run.prisma
Extends AI quote run audit writes with createdPricingQuoteIds and generatedInputs.
computeAiQuoteDiff.ts
New pure, IO-free diff module over unknown JSON, with zod schemas for the stored change array.
listeners/aiQuoteDiff.ts
New platform-event listener that maps existing quote events to capture triggers, computes the diff, and writes AiQuoteDiff.
migration.sql files
Additive migration plus a follow-up idempotency correction from unique platformEventId to unique (platformEventId, pricingQuoteId).

3. How it works

Photo 1: generated baseline

createPricingQuoteFromAiResult appends each created quote id and snapshots the saved input:

auditState.createdPricingQuoteIds = [
  ...(auditState.createdPricingQuoteIds ?? []),
  pricingQuote.id,
];

auditState.generatedInputs = {
  ...(auditState.generatedInputs ?? {}),
  [pricingQuote.id]: pricingQuote.input,
};
Photo 2: final sent quote

The listener reads the current PricingQuote.input after an existing platform event fires, then compares it to the frozen snapshot.

computeAiQuoteDiff(snapshot, quote.input)

Trigger mapping

Existing event Stored trigger Quote ids processed Skip rules
quote.approval_submitted APPROVAL_SUBMIT Anchor quoteId Skip non-v2, non-AI, or no snapshot.
quote.crm_submitted CRM_PUSH Anchor quoteId Same silent skips.
quote.exported ORDER_FORM_EXPORT Anchor plus bundled pricingQuoteIds, deduped Only captures format === "ORDER_FORM"; presentation/document exports are ignored.

Diff semantics

Typed changes stored in JSON
Change type Join key / scope Stored values
PRODUCT_ADDED / PRODUCT_REMOVED Product lines join by line id Compact line summary; absent side is null.
PRICE_CHANGED Same product line id Sparse subsets of price fields: quotePriceFlat, quotePricePercent, commitmentTierPrice, overageQuotePrice.
QUANTITY_CHANGED Same product line id Sparse subsets of volumeFlat and volumePercent.
TERM_CHANGED variableId, quote-level or product-level Previous and current userValue.
TERM_LENGTH_CHANGED Quote-level subscriptionTerms Previous and current term length.
MONTHLY_MINIMUM_CHANGED Quote-level minimum commitment fields Sparse subsets of minimumCommitment and legacy monthlyMinimum.
Defensive JSON behavior

The diff function accepts unknown on both sides because both values come from JSON columns. Invalid or unrecognized shapes return no recognizable changes instead of throwing inside the worker.

const generated = asRecord(generatedInput);
const current = asRecord(currentInput);
if (!generated || !current) return [];

4. What it doesn’t change

Explicit non-goals

5. Risks / rollback / open questions

Operational risk: worker failures can delay or miss training rows, but cannot block the user action that emitted the event. BullMQ retries apply, and duplicate inserts are suppressed by the unique (platformEventId, pricingQuoteId) index.
Migration risk

The migration is additive: enum, nullable/defaulted columns, new table, indexes, and an org FK. Rollback would remove the listener registration first, then drop the new table/enum/columns if needed.

Data interpretation risk

Product lines join on line id. Deleting and re-adding the same catalog product records as removed plus added, which is intentional but analysts should read it as editor behavior, not just catalog identity.

Known skip cases

Non-AI quotes, pre-snapshot AI runs, deleted quotes, non-order-form exports, and Dealops 1 events produce no row.

Validation status from PR: tsc clean, ESLint clean, and 14 new Mocha tests for diff computation plus listener trigger selection.