Harden AI quote generation: async jobs, validation, guardrails
Moves AI quote generation off the request path into a BullMQ job, wraps the model in a single validation/correction pass, and refuses to create a quote when deterministic guardrails fail.
generateAiQuote BullMQ job, a deterministic guardrail layer, and a single-shot LLM validation/correction pass that returns a corrected quote (not a patch). Suggested minimum commitment now has one canonical formula.
/api/ai-quoting/... no longer holds open an SSE while Gemini runs. The client starts a job, polls jobs.getStatus, and replays buffered events. Historical-average fallback quotes are gone.
PricingQuoteInput (extended, not replaced), and existing pricebook/L1/list-price loaders. Renewal opportunities are explicitly excluded from AI quoting.
1. Why this exists
PricingQuote could be created without the model ever producing a defensible recommendation.
2. The new pipeline
Step through the run. Each frame shows which component is doing work and how data flows through it. Use โ/โ or the dots.
/api/ai-quoting/start/:sfdcId/:v2Id with the pricebook + currency terms. The route enqueues a generateAiQuote BullMQ job and returns { jobName, jobId } immediately โ no Gemini call on the request path.
3. Before vs after โ the request path
The client opened an SSE to /stream/:id. The route ran Stages 2โ6 inline. The fetch stayed open for the entire model run (often 60โ120s).
GET /api/ai-quoting/stream/:sfdcId/:v2Id
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ orchestratorStream runs Gemini โ
โ โ SSE chunks: step:start, ... โ
โ โ SSE chunks: complete โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
client closes tab โ server keeps going
Stage 4 could fail and the orchestrator would emit ai:fallback_used with a historical-average quote. Banner copy lived in AIQuoteBanner.tsx.
The client POSTs to /start/:id, gets a job id back, then polls trpc.jobs.getStatus every 1s. Events are buffered on the job's progress field and replayed in order.
POST /api/ai-quoting/start/:sfdcId/:v2Id
โ { jobName, jobId }
BullMQ worker picks up job:
emit step events โ progress { sequence, events[] }
client polls jobs.getStatus.query(...)
โ drains events newer than lastSequence
No fallback quote. If generation fails, the job fails and the modal shows a structured "no quote created" reason.
4. The validation / correction loop
Stage 4 used to emit one recommendation and call it done. Now it goes through a one-shot validate-and-correct pass, then deterministic guardrails โ and deterministic guardrails are the source of truth.
generateAiQuote.ts calls Gemini with the quote_generation prompt + tool calls. Detects repeated identical tool-call loops and fails fast instead of spinning until a vague timeout.
validateAiRecommendationWithLLM.ts + quote_validate_correct.v1.md) returns a complete corrected JSON, not a patch. LLM-only hard issues are downgraded to guidance.
validateAiRecommendationAgainstRules.ts applies L1 floors, list caps, auto-select / auto-deselect rules, and the minimum-commitment formula. If hard issues remain, no quote is created.
generateValidatedAiRecommendation.ts โ if the LLM says "PRICE_BELOW_L1" but the deterministic check passes, it becomes a guidance note.
5. Pricing rules made deterministic
5a. Suggested minimum commitment formula
if flatMinimum == 0 โ $0
else โ roundUpTo500(max(flatMinimum, 500))
| Scenario | Raw calc | Final |
|---|---|---|
| Usage $3,372.50 / mo ยท Support $1,000 / mo | max(0, 3372.5 ร 0.6 โ 1000) = 1023.50 |
$1,500 |
| Usage $1,300 / mo ยท Support $1,000 / mo | max(0, 780 โ 1000) = 0 |
$0 |
| Usage $1,300 / mo ยท Support $500 / mo | max(0, 780 โ 500) = 280 |
$500 (floor) |
| Support ramp: 6mo @ $0, then $1,000 | Apply per-month โ ramp [1000ร6, 0ร6] |
monthlyMinimumRamp |
5b. L1 / list-price floors
- Flat price must be โฅ L1 when L1 exists
- Each tier price must be โฅ L1
- Non-zero
quotePriceRampmonths must be โฅ L1 - Zero ramp months are waivers โ not raised to L1
- All prices capped at active pricebook list price
- Preserve explicit support fee evidence
- Capped at list price when list exists
- Not raised to usage L1 floors
- Detected via
isSupportOrPlatformProduct()inl1PricingBasis.ts
5c. L1 lookup basis
L1 is tier-keyed by effective monthly minimum, not just usage revenue. That changes which tier is selected for the deal:
// if either is ramped, use the average
6. Auto-select & auto-deselect product rules
Pulled out of prompt-land into Stage 5's mapToV2QuotePayload in generateV2Quote.ts. The catalog now exposes autoSelectProductIds and autoDeselectProductIds per ProductSpec.
X declares autoSelectProductIds: ['Y'], Y is added even if the model didn't emit it. The mapper deduplicates and records reasoning in appliedRuleReasoning.
X declares autoDeselectProductIds: ['Z'], then Z is dropped from the quote when X is selected. Conflicting SKUs cannot both ship.
7. The failure UX
Errors surfaced raw model/tool internals. The fallback path silently created a quote based on historical averages with an amber banner.
"Gemini API quota exceeded for project ..."
"Tool call malloc failed in iteration 7"
โ Quote created anyway, fallback banner shown
Two distinct terminal states, both clean. Provider/tool details never reach the user.
- error โ pipeline crashed. Modal shows "Quote generation failed" + a structured Evidence/Benchmark/Strategy block when present.
- no_quote โ pipeline completed but guardrails refused. Shows "No quote created" with confidence pill + failed-rule list.
The client hook (useAIQuoteStream.ts) gained an isNoQuote branch alongside isCompleted / isError. The modal renders a new NoQuoteBanner that parses the reason into labeled sections.
8. Surfaced reasoning in the quote UI
When the quote does get created, the AI's reasoning is no longer trapped in a comment field. The pricing UI now reads aiMetadata from PricingQuoteInput:
minimumCommitmentTable.tsx renders an AIReasoningTooltip type="commitment" next to the minimum cell, showing the formula derivation.
InspectionDrawer.tsx shows applied-rule reasoning, validation-loop attempt count, and pass/guardrailed badge.
AIRecommendationPhaseRail walks through 9 activity labels โ query catalog, resolve IDs, validate, fix violations, pass guardrails โ driven by server-emitted phase data.
9. Server-side topology
| File | Status | Role |
|---|---|---|
jobs/definitions/generateAiQuote.ts | new | BullMQ job definition; calls the orchestrator and writes events to job progress |
pipeline/finalizeAiQuote.ts | new | Stage 5/6 wrapper โ filters products, builds PricingQuoteInput, creates PricingQuote (or returns no_quote) |
pipeline/generateValidatedAiRecommendation.ts | new | Orchestrates generate โ validate+correct โ guardrails; never falls back |
pipeline/validateAiRecommendationWithLLM.ts | new | Calls Gemini with quote_validate_correct.v1 prompt; returns full corrected JSON |
pipeline/validateAiRecommendationAgainstRules.ts | new | Deterministic guardrails: L1 floors, list caps, auto-select/deselect, commitment formula |
pipeline/l1PricingBasis.ts | new | effectiveMonthlyMinimum calc + isSupportOrPlatformProduct classifier |
pipeline/generateAiQuote.ts | +720/โ238 | Loop-detection, tool-batching, Stage4GenerationError typed errors (no fallback) |
pipeline/generateV2Quote.ts | +601/โ16 | Auto-select/deselect mapping, ramped flat-fee support, L1 floor raising |
routes/aiQuoting.ts | +120/โ813 | Big shrink โ route just enqueues the job, no more inline SSE |
10. What it doesn't change
- The
jobs.getStatustRPC contract โ this PR reuses the existing progress mechanism that powersuseExportPricingQuote - The pricebook loaders (
loadProductCatalog,loadListPriceMap); only addsloadL1PriceMap - The downstream
PricingQuotemodel โaiMetadatais an additive field onPricingQuoteInput - Manual quote creation paths (the
CreateQuoteButtonmenu still offers both, AI option just hides for renewals) - The product-dropped Stage-5 notice โ that path still fires when AI succeeds but a SKU fails catalog/name validation
11. Risks & rollback
0.6 ร usage โ support, round up to $500, floor at $500). This will produce different numbers than free-form LLM commitments did. Worth reviewing the focused Mocha tests in validateAiRecommendationAgainstRules.test.ts before merge.
12. Open questions
- Should the validation/correction LLM call be cached on the recommendation hash? Currently fires every run.
- The auto-select rule attribution in
appliedRuleReasoningcurrently says "for the active pricebook/currency" โ is that the right phrasing for the customer-facing reasoning surface, or should it stay internal? - Renewal opportunities are excluded from AI quoting at the button level. Is there a Stage-2 server-side guard too, or do we rely on the UI?
Diff was truncated in the input โ anything after the validation/correction module isn't directly inspectable here. The summary above reflects the PR description plus the visible portion of the diff.