Handle empty AI quote recommendations
Treat the "AI finished but recommended zero products" case as its own terminal SSE event instead of crashing through quote-creation as a failure.
no_quote SSE terminal event carrying the AI's reasoning, confidence, and run summary.
no_quote path1. Why this exists
The AI quoting pipeline emits SSE events as it walks an opportunity through stages (find similar accounts → recommend products → map SKUs → build quote). Previously there were two terminal events: complete (a real quote was built) and error (something blew up).
An empty recommendation — the AI ran clean but decided it didn't have enough product evidence for this account — fit neither bucket. The route would keep going into SpecService / quote creation with an empty products array, and downstream filtering would surface this as a quote-generation failure in the UI. Reviewers got a red error for what is actually a normal, low-confidence outcome.
no_quote — alongside completed and error.
2. Before / after pipeline shape
aiResult.payload.products = []error event → red modalproducts.length === 0no_quote, res.end()status: 'no_quote'3. What changes, file by file
Server: new terminal event type
apps/server/src/dealops2/aiQuoting/pipeline/streamTypes.ts adds 'no_quote' to the AIQuoteEventType union and a new NoQuoteEvent shape carrying everything the UI needs to render without a follow-up fetch:
export interface NoQuoteEvent {
type: 'no_quote';
success: true; // not an error
reason: string; // AI's overallReasoning prose
confidence: 'high' | 'medium' | 'low';
summary: {
targetAccount: string;
similarAccountsFound: number;
aiProductsRecommended: number;
mappedProducts: number;
};
timestamp: number;
}
The event is added to the AIQuoteEvent discriminated union so both ends stay type-checked.
Server: route short-circuit
apps/server/src/routes/aiQuoting.ts adds a guard immediately after the AI stage finishes and before SpecService spins up:
if (aiResult.payload.products.length === 0) {
emitter.emit(
createEvent<NoQuoteEvent>({
type: 'no_quote',
success: true,
reason:
aiResult.aiRecommendation?.overallReasoning ||
'The AI did not find enough product evidence …',
confidence: aiResult.aiRecommendation?.overallConfidence ?? 'low',
summary: { /* targetAccount, similarAccountsFound, … */ },
}),
);
res.end();
return;
}
Note this runs after auditState.stage5 = aiResult.stage5, so the audit record still captures the AI's full output — only the pricing-spec / quote-build path is skipped.
Client hook: new status + state field
useAIQuoteStream.ts extends the state machine:
status:
| 'idle'
| 'connecting'
| 'running'
| 'completed'
| 'no_quote' // new
| 'error'
noQuote: AIQuoteNoQuoteResult | null
isNoQuote: state.status === 'no_quote'
The SSE message handler gets a new case 'no_quote' branch that stores the payload and flips status. All three reset paths (initial, start, reset) zero out the new noQuote field alongside result.
Client modal: banner + parser + close-only footer
AIQuoteModal.tsx threads isNoQuote through HeaderBlock (subtitle becomes "No quote created") and renders a new <NoQuoteBanner> component. The footer condition expands from isError to isError || isNoQuote, giving the no-quote state its own close bar.
The interesting piece is the reasoning parser. The AI's overallReasoning tends to arrive as a flat blob with markdown-ish headings; parseNoQuoteReason splits it on Evidence: / Benchmark: / Strategy: headings, strips **bold** and bullet markers, and falls back to a single "Reason" section if no headings are found. Each section caps display at 3 lines.
4. What the no-quote banner looks like
An approximation of the rendered banner (real version is amber Tailwind classes on the client). Confidence pill is right-aligned, parsed reasoning sits below a divider.
5. How the parser handles messy AI prose
The parsing logic is defensive about the shape of the AI string:
- Normalize
\r\n→\nand trim. - Run a global regex for headings:
/(?:^|\n)\s*\*{0,2}(Evidence|Benchmark|Strategy)\*{0,2}:?\s*/gi. - No matches? Wrap the whole string as a single "Reason" section.
- Otherwise slice between consecutive heading matches, clean each line (strip
**, leading-/*/•), drop empties. - Drop sections that ended up with zero lines.
Display tops out at 3 lines per section — if the AI rambles, the banner stays compact.
6. What it doesn't change
- The
errorpath. Genuine pipeline failures still emitErrorEventand render the red banner. - The
completepath. Ifproducts.length > 0, the existing spec / quote-build flow runs unchanged. - Audit capture.
auditState.stage5is still set before the short-circuit, so the AI's reasoning is persisted regardless of outcome. - Banner notices. The existing
ai_fallback_used/products_droppednotices are untouched — they're partial-degradation signals, not terminal events. - SSE transport, route shape, cancel handling. No changes to how the stream opens or closes beyond adding one more terminal branch.
7. Risks and open questions
overallReasoning was an internal field; the parser assumes it tends to contain Evidence: / Benchmark: / Strategy: headings. If the AI prompt drifts and stops producing those headings, the banner gracefully degrades to a single "Reason" block — but the structured display goes away. Worth a prompt-side check that those headings are stable.
completed and error codepaths are not modified.
no_quote count toward any AI quality / fallback metrics in telemetry? Currently it's emitted with success: true, which suggests it shouldn't be counted as a failure — but it's also not a success in the "quote shipped" sense. Not addressed in this PR.