Overhaul extraction pipeline with new TradeItem model, conversation flow, and dedicated extraction endpoint. Add sidebar navigation with NavMenu component and landing page. Introduce few-shot prompting service and tests. Add prompt settings and email upload specs. Update OpenSpec tooling with improved export-spec and extract-feature commands. Archive completed changes and export full specs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
Feature: AI Chat Agent with Streaming & Rich Text
Target: Existing MudBlazor app (.NET 9 / Blazor WASM + ASP.NET Core API)
Includes: basic-chat-interface, wire-responses-api, migrate-to-semantic-kernel, multi-turn-conversations, add-test-coverage, enable-rich-text-display
Skipped: migrate-claude-md-to-openspec (project scaffolding only)
Integration Rule
This feature is additive only. Existing applicationX code takes precedence over this spec in all cases.
- DO NOT modify existing files, components, layouts, services, or patterns in the target
- DO NOT replace existing patterns (e.g., if the target uses a different HttpClient pattern, use theirs)
- DO add new files, new nav links, new routes, new DI registrations
- DO conform to the target's existing code style, naming, and project structure
- If the target already has an equivalent service (markdown renderer, HTTP wrapper, etc.), use theirs
- If a task conflicts with existing target code, stop and notify the user — do not skip silently; the user decides whether to skip, adapt, or redesign
Assumes
- .NET 9 solution with Blazor WASM client + ASP.NET Core API projects + Shared class library
- MudBlazor 9.2.0 installed and configured (AddMudServices, providers in MainLayout)
- MudLayout with MudAppBar and MudMainContent in MainLayout.razor
Target Layout (CRC app)
|------ ~220px ------|-------------- fluid ---------------|
| | ≡ CRC 0.0.0 APR-CRC-PROD-... | ← MudAppBar (regular, ~64px)
| Home |------------------------------------|
| Pricer | |
| Market Data | MudMainContent (@Body) |
| XVA Statistics | ← Chat page renders here |
| Sales | |
| Sales Assistant ★ | |
| | |
| MudDrawer (~220px) | |
|---------------------|-----------------------------------|
- MudAppBar: regular height (~64px), not Dense — hamburger toggle, title + version, env badge
- MudDrawer: left, ~220px, toggled by hamburger, contains MudNavMenu
- MudMainContent: fluid width, pages render via @Body
- "Sales Assistant" added as new MudNavLink in existing MudNavMenu
- Chat page renders inside MudMainContent — must not set its own AppBar or layout
- Available viewport:
height: calc(100vh - 64px), width: fluid minus drawer when open
Packages
API project:
Microsoft.SemanticKernel1.74.0Microsoft.SemanticKernel.Connectors.OpenAI1.74.0
Client project:
Microsoft.Extensions.Http9.0.4Markdig1.1.1
Test projects (xUnit):
xunit,xunit.runner.visualstudio,Microsoft.NET.Test.Sdk- API tests:
Moq,Microsoft.AspNetCore.Mvc.Testing - Client tests:
Moq
Architecture
Three-project solution: WASM client sends chat requests to ASP.NET Core API, which processes them through Semantic Kernel (pointed at an OpenAI-compatible endpoint) and streams token deltas back as SSE. Client renders assistant markdown as sanitized HTML. An extraction plugin demonstrates SK tool calling.
Shared Models
ChatMessage — Shared/Models/
string Role("user" | "assistant"),string Content,DateTime Timestamp
ChatRequest — Shared/Models/
List<ChatMessage> Messages
HealthResponse — Shared/Models/
string Status,DateTime Timestamp
ExtractedFields — Shared/Models/
- Required:
string? Client,string? Project,decimal? Hours,decimal? Rate,string? Currency,string? Date - Optional:
string? Description,string? PoNumber
ValidationResult — Shared/Models/
bool IsValid,List<string> Errors
Components
API: Program.cs
- Register
AddControllers(),AddOpenAIChatCompletion()with configurable endpoint,AddKernel(), ExtractionPlugin singleton - CORS policy allowing Blazor client origin, any header/method
- Config keys:
ResponsesApi:BaseUrl(defaulthttp://localhost:8317/v1),ResponsesApi:Model,ResponsesApi:ApiKey - IMPORTANT: Base URL must include
/v1— the OpenAI SDK appendschat/completionsdirectly - Add
public partial class Program { }for test accessibility
API: ChatController — Controllers/
- POST
/api/chatacceptsChatRequest, returnstext/event-stream - Get
IChatCompletionServicefrom Kernel, convert messages toChatHistory - Import ExtractionPlugin from DI:
_kernel.ImportPluginFromObject(plugin, "Extraction") - Use
OpenAIPromptExecutionSettingswithFunctionChoiceBehavior.Auto() - Stream via
GetStreamingChatMessageContentsAsync(), emit non-empty chunks as SSE - SSE format:
data: {"text":"<delta>"}\n\n, completion:data: [DONE]\n\n, error:data: {"error":"<msg>"}\n\n - Catch
HttpRequestException→ error SSE event,TaskCanceledException→ silent (client disconnect)
API: HealthController — Controllers/
- GET
/api/health→ returnsHealthResponsewith status "Healthy" and UTC timestamp
API: ExtractionPlugin — Plugins/
[KernelFunction("validate_extracted_fields")]method accepting JSON string- Deserialize to
ExtractedFields, validate required fields non-null, Hours/Rate > 0 - Return
ValidationResultas JSON string
Client: Program.cs
- Register
AddMudServices(),MarkdownService(singleton) - Register
AddHttpClient<ChatApiClient>with API base URL from config - Config:
ApiBaseUrl_Https,ApiBaseUrl_Httpin wwwroot/appsettings.json - Auto-detect HTTPS from
HostEnvironment.BaseAddress
Client: ChatApiClient — Services/
- Typed HttpClient wrapper
GetHealthAsync()→ GET api/health, returnsHealthResponse?SendChatStreamingAsync(ChatRequest)→IAsyncEnumerable<string>- Build
HttpRequestMessagemanually - Call
httpRequest.SetBrowserResponseStreamingEnabled(true)(Blazor WASM extension) - Send with
HttpCompletionOption.ResponseHeadersRead - Parse SSE line-by-line: extract
"text"field, throw on"error"field, stop on[DONE] - SSE loop: see "Critical Patterns" section below — do NOT use
reader.EndOfStream
- Build
Client: MarkdownService — Services/
- Singleton wrapping Markdig with
UseAdvancedExtensions()pipeline ConvertToHtml(string markdown)→ sanitized HTML string- Two-pass sanitization:
- Strip
<script>and<style>blocks with content (regex) - Filter tags against allowlist: p, h1-h6, strong, em, code, pre, ul, ol, li, a, table, thead, tbody, tr, th, td, br, blockquote
- Strip
- Only
hrefattribute allowed (on<a>only)
Client: SalesAssistant.razor — Pages/, route /sales-assistant
- Page title: "Sales Assistant"
- Add
MudNavLinkin existing sidebar NavMenu: iconIcons.Material.Filled.SmartToy, href/sales-assistant - Message list with MudPaper bubbles: user (right-aligned, primary bg), assistant (left-aligned, surface bg)
- MudTextField with send icon adornment, Enter key handler, disabled during streaming
- Empty state with centered title text
- "New Chat" button (MudButton) above input, visible when messages exist, disabled during streaming
- Streaming: append tokens to assistant message, call
StateHasChanged()+ auto-scroll per token - Assistant messages: render via
(MarkupString)GetRenderedHtml(message)inside.markdown-bodydiv - Rendered HTML cache (
Dictionary<ChatMessage, string>) — cache on stream completion, render fresh during streaming - Auto-scroll via JS interop:
document.querySelector('.message-list').scrollTop = .scrollHeight - Thinking indicator:
MudProgressCircularwhen assistant content is empty and streaming - Multi-turn: send all messages with non-empty content (excludes empty assistant placeholder)
- User messages: plain
MudText, no markdown
Client: SalesAssistant.razor.css — scoped styles
.chat-container: flex column,height: calc(100vh - 64px), max-width 800px, centered within MudMainContent.message-list: flex 1, overflow-y auto, flex column, gap 0.75rem- Message alignment:
.message-userflex-end,.message-assistantflex-start ::deep .bubble-user: primary color bg, white text, rounded except bottom-right::deep .bubble-assistant: surface bg, border, rounded except bottom-left::deep .markdown-bodystyles: code blocks (gray bg, monospace), tables (bordered), blockquotes (left border primary), headings (scaled down), links (primary color, underline)
Wiring (dependency order)
- Shared project: Create all models (ChatMessage, ChatRequest, HealthResponse, ExtractedFields, ValidationResult)
- API Program.cs: AddControllers → AddOpenAIChatCompletion → AddKernel → AddSingleton → AddCors → Build → UseCors → UseAuthorization → MapControllers
- Client Program.cs: AddMudServices → AddSingleton → AddHttpClient → Build → RunAsync
- API appsettings.json:
{"ResponsesApi": {"BaseUrl": "http://localhost:8317/v1", "Model": "claude-sonnet-4-6"}} - Client wwwroot/appsettings.json:
{"ApiBaseUrl_Http": "http://localhost:7000", "ApiBaseUrl_Https": "https://localhost:7100"}
Critical Patterns (copy these, do not improvise)
SSE read loop in Blazor WASM
// DO NOT use reader.EndOfStream — it does a synchronous peek,
// which Blazor WASM's fetch-backed stream rejects at runtime.
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (!line.StartsWith("data: ")) continue;
var data = line.Substring(6);
if (data == "[DONE]") yield break;
// parse {"text":"..."} or {"error":"..."}
}
Chat container height
/* 64px = MudAppBar regular height in the CRC app.
The chat page renders inside MudMainContent which already
sits below the AppBar, so use 100% of the parent height
rather than calculating from viewport. If MudMainContent
does not have height set, fall back to calc(100vh - 64px). */
.chat-container {
height: calc(100vh - 64px);
/* MudDrawer width is handled by MudLayout — the chat container
is fluid within MudMainContent, no horizontal calc needed. */
}
Behavior (non-obvious)
- Base URL must include
/v1— SK's OpenAI connector appendschat/completionsdirectly - WASM streaming requires both
SetBrowserResponseStreamingEnabled(true)andResponseHeadersRead - Only emit SSE chunks where
!string.IsNullOrEmpty(chunk.Content)— tool call chunks have no text - Cache rendered HTML for completed messages to avoid re-running Markdig on every
StateHasChanged public partial class Program { }at bottom of API Program.cs forWebApplicationFactory<Program>in tests- ExtractionPlugin is imported per-request via
ImportPluginFromObject(not at Kernel build time) - Chat sends all messages with non-empty content — filters out the empty assistant placeholder
- Chat container height assumes MudAppBar Dense (48px) — if your AppBar height differs, adjust the calc() or use a CSS variable
- On mobile viewports,
100vhincludes the browser address bar — use100dvhif targeting mobile
Test Coverage
- HealthControllerTests: GET /api/health returns 200 with valid HealthResponse
- ChatControllerTests: Mock
IChatCompletionService, verify SSE text deltas + [DONE]; verify error handling; useWebApplicationFactory<Program> - ExtractionPluginTests: Valid fields → IsValid; missing required → errors; invalid JSON → error; zero hours → error
- ChatApiClientTests: Mock
HttpMessageHandlerwith canned SSE streams; verify delta parsing order; verify error event throws - MarkdownServiceTests: Verify rendering (bold, italic, code, lists, tables, links, headings); verify sanitization (script/style stripping, event handler removal, tag allowlist)