AI Quoting: ABI semantics, 1k-unit tiers, and Transfer guardrails
Locks down three classes of model error in the Dealops 2 AI quoting pipeline — ABI shorthand, malformed price tiers, and Transfer rail/risk/mode bundles — at both the prompt and deterministic-guardrail layers.
Three new families of hard rules — ABI scope, strict 1,000-unit tier boundaries, and Transfer bundle / pricing-mode constraints — enforced in both the LLM prompts and deterministic Stage 4/5 code.
Tier validation moves from "non-overlap + max-coverage" to strict contiguous 1k bands. Transfer bps SKUs now map to quotePricePercent with an avgTransactionSize term instead of flat pricing.
Pipeline shape, orchestrator wiring, L1/list-price guardrails, ramp logic, and the existing validate-and-correct one-shot pattern all stay intact. No schema migration.
1. Why this exists
The AI quoting agent (Dealops 2, apps/server/src/dealops2/aiQuoting/pipeline) was producing three recurring classes of bad quotes that the existing guardrails couldn't catch:
When a buyer said "ABI", the model sometimes resolved it to the Auth & Identity bundle SKU instead of the literal three products Auth, Balance, Identity. Same TLA, different commercial outcome.
The agent emitted tiers like 0–1000 / 1001–10M or arbitrary thresholds (500, 2500). Non-overlap alone didn't enforce the actual Plaid commercial convention: clean 1,000-unit bands.
Transfer base SKUs were shipped without the four companion event SKUs, or with both bps and Fixed variants for the same rail/risk combo, or with a pricing mode the average transaction size didn't support.
The fix is symmetric: add the rules to the three prompts (quote_generation.v1.md, quote_validation.v1.md, quote_validate_correct.v1.md) so the model knows them, and add deterministic checks in validateAiRecommendationAgainstRules.ts and generateV2Quote.ts so the model can't slip past them.
2. The three new rule families
2.1 ABI means exactly three products
Buyer says "ABI". Model could pick the Auth & Identity bundle SKU and silently drop Balance.
QUOTE Auth & Identity bundle
ABI in opportunity context → Auth, Balance, Identity are all required. Missing any of the three triggers ABI_PRODUCT_MISSING.
QUOTE Auth + Balance + Identity
Detection lives in aiQuoteRuleHelpers.ts:
export function contextMentionsAbiProducts(text: string): boolean {
const lower = text.toLowerCase();
return (
/\babi\b/.test(lower) ||
(/\bauth\b/.test(lower) &&
/\bbalance\b/.test(lower) &&
/\bidentity\b/.test(lower))
);
}
The validator walks ABI_PRODUCT_NAMES = ['Auth', 'Balance', 'Identity'], looks each up in the catalog, and emits a hard issue per missing product. The fixture transcripts in fetchOpportunityContext.ts were updated so the demo opp explicitly says "ABI scope: Auth, Balance, and Identity only" to exercise the rule end-to-end.
2.2 Price tiers must use strict 1,000-unit bands
The old check in generateV2Quote.ts only verified two things: tiers don't overlap, and the highest tier covers up to a 1M sentinel. Anything in between — like 0–999 / 1001–10M — slipped through.
The new rule, in validateStrictPriceTiers, demands:
Between 2 and 10 tiers per product. Single-tier "tiers" should be a flat price; more than 10 is operationally unmanageable.
First tier starts at 0. Each subsequent tier starts at i * 1000. Previous tier's toUnits must equal fromUnits − 1.
Final tier's toUnits must reach at least the TIER_MAX_COVERAGE_SENTINEL = 1_000_000 mark — no finite cap on the top tier.
| Tier shape | Old result | New result |
|---|---|---|
0–999, 1000–1_000_000 | ✅ pass | PASS |
0–1000, 1000–1_000_000 (overlap at 1000) | ❌ "overlap" | "must end at 999 units" |
0–499, 500–2000 (wrong boundary) | ❌ "overlap" | "must start at 1000 units" |
0–999, 1000–1999 (finite cap) | ❌ "max-coverage" | "final tier must cover open-ended usage" |
| 11 tiers | ✅ pass | "tier count 11 exceeds maximum 10" |
Both isTierShapeValid (Stage 5 mapping) and hasValidPriceTiers (Stage 4 validator) now delegate to the shared helper, so the rule is enforced exactly once in code.
2.3 Transfer rail × risk × pricing mode
Transfer SKUs have a structured name that encodes three dimensions. The new parsers in aiQuoteRuleHelpers.ts split them out and the validator reasons about the bundle as a whole.
// Matches: "Transfer (Standard ACH) - Vanilla Originators - bps"
/^Transfer \((Standard ACH|Same-day ACH)\) - (Vanilla Originators|High Risk Customers) - (bps|Fixed)$/i
// Matches: "Transfer (Incoming Wire) - Vanilla Originators"
/^Transfer \((All other returns|Incoming Wire|NOC|Unauthorized returns)\) - (Vanilla Originators|High Risk Customers)$/i
Given a parsed base product with a comboKey of rail::riskTier, four checks run:
For each base SKU, the same risk tier must include all four event SKUs: All other returns, Incoming Wire, NOC, Unauthorized returns. Missing any → TRANSFER_COMPANION_MISSING.
If the quote contains both bps and Fixed for the same rail+risk, that's TRANSFER_MODE_CONFLICT. Exactly one mode is allowed.
Average transaction size determines the mode (see band below). Wrong mode for the ATS → TRANSFER_PRICING_MODE_MISMATCH.
If ATS can't be extracted from any call/opp text, the quote doesn't get to guess — TRANSFER_AVG_TRANSACTION_SIZE_MISSING blocks generation.
The pricing-mode band:
< $100 | $100 ← bps band → $600 | > $600
ATS extraction is regex-based against the assembled context text (opp name, primary product, use case, secondary product, technical use cases, plus every Gong call's subject/brief/key points/next steps/full description). Three patterns catch variants like "average transaction size is $250", "txn size: ~250 avg", and "$250 avg ticket".
3. Where the rules fire
Each rule is enforced at three layers — prompt, LLM validator, deterministic code — so a regression at one layer doesn't ship a bad quote.
| Rule | Prompt | LLM validate/correct | Deterministic guardrail |
|---|---|---|---|
| ABI = Auth + Balance + Identity | quote_generation.v1.md §ABI Shorthand |
Rule 8 in both validation prompts | ABI_PRODUCT_MISSING |
| 1k-unit tier bands, ≤10 tiers, open-ended tail | quote_generation.v1.md §Tiered Pricing |
Rule 9 in both validation prompts | PRICE_TIER_SHAPE_INVALID (Stage 4) + isTierShapeValid (Stage 5) |
| Transfer companions required | quote_generation.v1.md §Transfer |
Rule 10 | TRANSFER_COMPANION_MISSING |
| Transfer pricing mode bands | quote_generation.v1.md §Transfer |
Rule 11 | TRANSFER_PRICING_MODE_MISMATCH, TRANSFER_MODE_CONFLICT, TRANSFER_AVG_TRANSACTION_SIZE_MISSING |
4. Transfer bps → percent pricing wiring
The other half of the Transfer story isn't validation — it's mapping. Previously every product became a quotePriceFlat. bps Transfer SKUs need quotePricePercent with floor/ceiling caps, and an avgTransactionSize term carried alongside.
The mapping path now branches on a new catalog field calculationSpec.bpsPercentPricing:
function buildProductPricingInput(params) {
// ...
if (isBpsPercentCatalogProduct(productEntry)) {
return {
quotePricePercent: buildQuotePricePercent({ /* rate + floor + ceiling */ }),
...(terms ? { terms } : {}),
};
}
return {
quotePriceFlat: buildQuotePrice(/* ... */),
...(terms ? { terms } : {}),
};
}
The resulting product payload for a Transfer bps SKU looks like:
{
quotePricePercent: {
type: 'percentWithLimits',
value: { type: 'percent', value: 0.1 },
perTxnFloor: { type: 'currency', value: 0.01, currency: 'USD' },
perTxnCeiling:{ type: 'currency', value: 2, currency: 'USD' },
},
terms: [{
type: 'termInput',
id: 'avgTransactionSize',
variableId: 'avgTransactionSize',
userValue: { type: 'currency', value: 250, currency: 'USD' },
}],
}
ProductInput in generateV2Quote.ts gained two new optional fields (quotePricePercent, terms) and finalizeAiQuote.ts propagates them through quote creation. ProductCatalogEntry in generateAiQuote.ts gained a structured calculationSpec carrying requiresTransactionSize, bpsPercentPricing, bpsPerTxnFloor, bpsPerTxnCeiling, and a transactionSizeBand.
5. New issue codes and what they tell the model
Each new code maps to a human-readable fallback in generateValidatedAiRecommendation.ts and a "feedback" string the corrector sees on the next pass:
| Code | Fires when | Corrector feedback |
|---|---|---|
ABI_PRODUCT_MISSING | Context mentions ABI but Auth/Balance/Identity missing | "Add X; ABI maps exactly to Auth, Balance, and Identity." |
PRICE_TIER_SHAPE_INVALID | Tiers fail 1k-band / 2–10 / open-tail check | Regenerate tiers using "0-999, 1000-1999, 2000-10000000" |
TRANSFER_AVG_TRANSACTION_SIZE_MISSING | Transfer base in quote, no ATS in any context text | "Do not guess the Transfer pricing mode." |
TRANSFER_PRICING_MODE_MISMATCH | Mode picked doesn't match the ATS band | Replace with the expected variant (bps or Fixed) |
TRANSFER_MODE_CONFLICT | Both bps and Fixed for same rail/risk | Select exactly one mode for this combo |
TRANSFER_COMPANION_MISSING | Base SKU missing one of the four event SKUs | Add the named companion in the same risk tier |
6. New shared module: aiQuoteRuleHelpers.ts
The PR consolidates rule logic that was previously duplicated between Stage 4 (validation) and Stage 5 (V2 mapping) into one file. Both stages now import from it.
PRICE_TIER_VOLUME_INCREMENT = 1000, MAX_PRICE_TIERS_PER_PRODUCT = 10, TIER_MAX_COVERAGE_SENTINEL = 1_000_000, ABI_PRODUCT_NAMES, TRANSFER_COMPANION_EVENTS.
validateStrictPriceTiers(tiers) — single source of truth, returns null on success or a human-readable reason. Used by both Stage 4 and Stage 5.
parseTransferBaseProductName / parseTransferCompanionProductName — regex-based decomposition into rail / risk / mode / event.
extractAverageTransactionSizeFromText — three regex variants over the full opp+calls text, returns first positive match or null.
opportunityContextToRuleText — moved out of the validator. Builds the single text blob ABI / ATS detection runs against.
isBpsPercentCatalogProduct, requiresAverageTransactionSize, findTransferCompanionByEvent — used by the V2 mapper to decide percent vs flat and to attach terms.
7. Test coverage added
Two test files grow significantly; one is rewritten to match the new tier rules.
±10/-10. Existing tier-shape and tier-semantic tests retargeted: overlap/coverage messages replaced with the new "must end at 999 units" / "must start at 1000 units" / "final tier must cover open-ended usage" assertions.
+109. New E2E case: a Transfer Standard ACH bps SKU with ATS=$250 in the opportunity use-case text maps to quotePricePercent with the right floor/ceiling and emits an avgTransactionSize term of $250 USD.
+361. Seven new cases: ABI missing/satisfied, tier 1k-boundary rejection, >10 tiers rejection, Transfer ATS-missing block, wrong-mode rejection, bps+Fixed conflict, missing-companion rejection, and a clean Transfer bps pass with all companions + ATS.
8. What it doesn't change
- No Prisma migration.
calculationSpeconProductCatalogEntryis a typed surface over fields already present onps(the loaded product spec). - Orchestrator/streaming wiring is unchanged structurally —
orchestrator.tsandorchestratorStream.tseach gain a singleopportunityContextargument forwarded intomapToV2QuotePayload. - L1 / list-price floor and cap behavior is unchanged. Transfer bps pricing still passes through
constrainPriceToL1AndList. - Ramp logic, minimum-commitment formula, auto-select/auto-deselect rules, and confidence filtering are all untouched.
- The validate-and-correct one-shot pattern stays. New codes plug into the existing
AIRuleValidationCodeunion and feedback fallback table. - Dealops 1 is not touched. This is entirely
dealops2/aiQuoting.
9. Risks and open questions
extractAverageTransactionSizeFromText cover the obvious phrasings ("average transaction size is $250", "$250 avg ticket"), but unusual call-note phrasings could miss and trigger TRANSFER_AVG_TRANSACTION_SIZE_MISSING on a quote that should have proceeded. The failure mode is conservative (block, don't guess), but it will show up as a "blocked quote" in production if the regexes miss real evidence.
"Transfer (Incoming Wire) - Vanilla Originators". If the catalog ever re-spells these (e.g. "Incoming Wires"), the parsers silently stop matching and the bundle rule stops firing. No fallback by spec-id.