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.
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.
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.
Capture runs in a BullMQ platform-event listener, not on the approval/CRM/export request path. Retries are idempotent per (platformEventId, pricingQuoteId).
Fourteen new Mocha tests cover diff semantics, malformed JSON degradation, storage-schema validation, and event-to-trigger mapping.
1. Why this exists
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.
Each departure moment records the final delta from the original AI output.
- signalNet rep correction, measured once the quote leaves the editor.
- useful empty state
changes: []means “sent exactly as generated.”
2. What changes
AiQuoteDiff stores one row per quote departure moment with typed JSON changes.
createdPricingQuoteIds and generatedInputs make multi-offer AI runs findable and diffable.
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.tsPricingQuote.input immediately after quote creation and stores it in the audit state by quote id.audit.ts + ai-quoting-run.prismacreatedPricingQuoteIds and generatedInputs.computeAiQuoteDiff.tslisteners/aiQuoteDiff.tsAiQuoteDiff.migration.sql filesplatformEventId to unique (platformEventId, pricingQuoteId).3. How it works
input is copied into audit state as generatedInputs[quoteId].
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,
};
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
| 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
- No UI is added; this is internal training/analysis data only.
- No new quote approval, CRM push, or export hooks are introduced; the listener subscribes to existing platform events.
- No request-path diff computation is added; writes happen in the worker process.
- No backfill is attempted; AI quote runs without
generatedInputsare skipped silently. - No Dealops 1 pricing flow support is added; the listener skips events where
payload.version !== "v2". - No FK is added to
PricingQuote,AiQuotingRun, or user ids; onlyOrganizationis relational for tenancy and cascade deletion. - No broad quote-input diffing is attempted; v1 tracks product, price, quantity, term, term length, and minimum commitment corrections.
5. Risks / rollback / open questions
(platformEventId, pricingQuoteId) index.
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.
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.
Non-AI quotes, pre-snapshot AI runs, deleted quotes, non-order-form exports, and Dealops 1 events produce no row.
tsc clean, ESLint clean, and 14 new Mocha tests for diff computation plus listener trigger selection.