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.

PR dealops#6168 Author @yijunz166 Branch yijunz/ai-no-quote-empty-recommendation Files 4 Diff +199 / −6 Area Dealops 2 · AI Quoting

Problem
When the AI agent finished analysis but recommended zero products, the pipeline kept marching toward quote creation and surfaced the empty result as a generic quote-generation failure.
Fix
Server short-circuits before quote creation and emits a new typed no_quote SSE terminal event carrying the AI's reasoning, confidence, and run summary.
UX
Modal renders a neutral amber "No quote created" banner with parsed Evidence / Benchmark / Strategy sections and a close-only footer — no quote, no error.
Client (modal + hook)
Server route
Stream types
New no_quote path

1. 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.

The mental model that changes: "AI finished with zero products" is no longer a failure mode. It's a third terminal status — no_quote — alongside completed and error.

2. Before / after pipeline shape

Before
1 AI stage 5 completes
2 aiResult.payload.products = []
3 Load pricing spec, build quote
4 Empty quote → looks like failure
! error event → red modal
After
1 AI stage 5 completes
2 Check products.length === 0
Emit no_quote, res.end()
Hook sets status: 'no_quote'
Amber banner with parsed reasoning

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 union
status:
  | 'idle'
  | 'connecting'
  | 'running'
  | 'completed'
  | 'no_quote'   // new
  | 'error'
State + flag
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.

No quote created
The agent completed analysis but did not find enough product evidence to recommend SKUs.
low
Evidence
Only 2 similar accounts found; neither had overlapping product purchases in the last 12 months.
Benchmark
Account industry segment is sparsely represented in the training cohort.
Strategy
Recommend manual SKU selection by the AE rather than a low-confidence auto-quote.

5. How the parser handles messy AI prose

The parsing logic is defensive about the shape of the AI string:

  1. Normalize \r\n\n and trim.
  2. Run a global regex for headings: /(?:^|\n)\s*\*{0,2}(Evidence|Benchmark|Strategy)\*{0,2}:?\s*/gi.
  3. No matches? Wrap the whole string as a single "Reason" section.
  4. Otherwise slice between consecutive heading matches, clean each line (strip **, leading -/*/), drop empties.
  5. 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

7. Risks and open questions

Reasoning string is user-facing now. Previously 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.
Low blast radius. The server change is a guarded early return; the client changes are additive (new status, new state field, new banner). Existing completed and error codepaths are not modified.
Open question: should 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.