feat: add extraction schema, sidebar nav, few-shot prompting, and prompt settings

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>
This commit is contained in:
local
2026-04-06 23:39:23 +01:00
parent 7a5c22593a
commit 5b027eb0db
83 changed files with 4242 additions and 296 deletions

View File

@@ -0,0 +1,206 @@
# 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)