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>
207 lines
12 KiB
Markdown
207 lines
12 KiB
Markdown
# 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.SemanticKernel` 1.74.0
|
|
- `Microsoft.SemanticKernel.Connectors.OpenAI` 1.74.0
|
|
|
|
**Client project:**
|
|
- `Microsoft.Extensions.Http` 9.0.4
|
|
- `Markdig` 1.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` (default `http://localhost:8317/v1`), `ResponsesApi:Model`, `ResponsesApi:ApiKey`
|
|
- IMPORTANT: Base URL must include `/v1` — the OpenAI SDK appends `chat/completions` directly
|
|
- Add `public partial class Program { }` for test accessibility
|
|
|
|
### API: `ChatController` — Controllers/
|
|
- POST `/api/chat` accepts `ChatRequest`, returns `text/event-stream`
|
|
- Get `IChatCompletionService` from Kernel, convert messages to `ChatHistory`
|
|
- Import ExtractionPlugin from DI: `_kernel.ImportPluginFromObject(plugin, "Extraction")`
|
|
- Use `OpenAIPromptExecutionSettings` with `FunctionChoiceBehavior.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` → returns `HealthResponse` with 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 `ValidationResult` as JSON string
|
|
|
|
### Client: `Program.cs`
|
|
- Register `AddMudServices()`, `MarkdownService` (singleton)
|
|
- Register `AddHttpClient<ChatApiClient>` with API base URL from config
|
|
- Config: `ApiBaseUrl_Https`, `ApiBaseUrl_Http` in wwwroot/appsettings.json
|
|
- Auto-detect HTTPS from `HostEnvironment.BaseAddress`
|
|
|
|
### Client: `ChatApiClient` — Services/
|
|
- Typed HttpClient wrapper
|
|
- `GetHealthAsync()` → GET api/health, returns `HealthResponse?`
|
|
- `SendChatStreamingAsync(ChatRequest)` → `IAsyncEnumerable<string>`
|
|
- Build `HttpRequestMessage` manually
|
|
- 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`
|
|
|
|
### Client: `MarkdownService` — Services/
|
|
- Singleton wrapping Markdig with `UseAdvancedExtensions()` pipeline
|
|
- `ConvertToHtml(string markdown)` → sanitized HTML string
|
|
- Two-pass sanitization:
|
|
1. Strip `<script>` and `<style>` blocks with content (regex)
|
|
2. Filter tags against allowlist: p, h1-h6, strong, em, code, pre, ul, ol, li, a, table, thead, tbody, tr, th, td, br, blockquote
|
|
- Only `href` attribute allowed (on `<a>` only)
|
|
|
|
### Client: `SalesAssistant.razor` — Pages/, route `/sales-assistant`
|
|
- Page title: "Sales Assistant"
|
|
- Add `MudNavLink` in existing sidebar NavMenu: icon `Icons.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-body` div
|
|
- 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: `MudProgressCircular` when 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-user` flex-end, `.message-assistant` flex-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-body` styles: code blocks (gray bg, monospace), tables (bordered), blockquotes (left border primary), headings (scaled down), links (primary color, underline)
|
|
|
|
## Wiring (dependency order)
|
|
|
|
1. **Shared project**: Create all models (ChatMessage, ChatRequest, HealthResponse, ExtractedFields, ValidationResult)
|
|
2. **API Program.cs**: AddControllers → AddOpenAIChatCompletion → AddKernel → AddSingleton<ExtractionPlugin> → AddCors → Build → UseCors → UseAuthorization → MapControllers
|
|
3. **Client Program.cs**: AddMudServices → AddSingleton<MarkdownService> → AddHttpClient<ChatApiClient> → Build → RunAsync
|
|
4. **API appsettings.json**: `{"ResponsesApi": {"BaseUrl": "http://localhost:8317/v1", "Model": "claude-sonnet-4-6"}}`
|
|
5. **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
|
|
```csharp
|
|
// 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
|
|
```css
|
|
/* 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 appends `chat/completions` directly
|
|
- WASM streaming requires **both** `SetBrowserResponseStreamingEnabled(true)` and `ResponseHeadersRead`
|
|
- 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 for `WebApplicationFactory<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, `100vh` includes the browser address bar — use `100dvh` if targeting mobile
|
|
|
|
## Test Coverage
|
|
|
|
- **HealthControllerTests**: GET /api/health returns 200 with valid HealthResponse
|
|
- **ChatControllerTests**: Mock `IChatCompletionService`, verify SSE text deltas + [DONE]; verify error handling; use `WebApplicationFactory<Program>`
|
|
- **ExtractionPluginTests**: Valid fields → IsValid; missing required → errors; invalid JSON → error; zero hours → error
|
|
- **ChatApiClientTests**: Mock `HttpMessageHandler` with 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)
|