# Porting Guide: Natural Language XVA Pricer → CRC ## Source: ChatAgent (commit 5b027eb) | Export: nlxva-pricer-spec.md ## Date: 2026-04-07 --- ## How to Use This Guide You have three companion documents for this port: 1. **`nlxva-pricer-spec.md`** — The AI-targeted portable spec. Compact, precise. Give this to Copilot/Claude on the CRC machine. It has the exact contracts, code patterns, and DI wiring. 2. **`nlxva-pricer-openspec.md`** — The OpenSpec bundle (proposal, design, tasks). Feed this to `/opsx:apply` on the target. 3. **This guide** — For you, the human. Read it when: - You're about to start and want the full picture (read Architecture Overview) - The AI agent hits a problem and you need to decide how to fix it (read Task Notes + Troubleshooting) - You want to understand WHY something was designed a certain way (read Design Decisions) - You need to find a config value or verify a setup step (read Configuration Checklist) **Workflow**: Let the AI agent do the heavy lifting via `/opsx:apply`. Consult this guide when it gets stuck or when you need to make an adaptation judgment call. --- ## Architecture Overview The Natural Language XVA Pricer is a chat-based interface that lets the CVA desk interact with an AI agent to price trades using natural language. It serves two modes: **general chat** (ask questions about XVA pricing, get explanations) and **email extraction** (upload a sales email, get structured trade data back as JSON). The data flows like this: The user types a message or drops an email `.html` file onto the chat area. The Blazor WASM client sends the request to the ASP.NET Core backend via HTTP POST. The backend processes it through **Microsoft Semantic Kernel** — an AI orchestration framework that connects to **Azure OpenAI** using Azure AD authentication (the same tenant CRC already uses). For extraction requests, the backend prepends **few-shot examples** (real email → expected JSON pairs loaded from disk) to teach the model the expected output format. The LLM can autonomously call **validation tools** (counterparty lookup, trade ID validation, currency validation, schema validation) via SK's automatic function calling. The response streams back token-by-token as **Server-Sent Events (SSE)**, and the client renders each token into the chat UI with **markdown formatting** and **XSS sanitization**. The external dependencies are: (1) an Azure OpenAI resource with a deployed model (authenticated via Azure AD tenant ID), (2) three external APIs for validation (counterparty, trade, currency) — these are the existing CRC backend services that CRC.Server already integrates with, and (3) the `Markdig` NuGet package for markdown rendering plus `Microsoft.SemanticKernel` for LLM orchestration. **The one thing you must understand**: this feature is an isolated page. It doesn't need Fluxor, doesn't modify CRC's data layer, and doesn't touch the Pricer/MarketData/XVA/Sales pages. It adds a controller, some services, a page, and a nav link. If something goes wrong during porting, the blast radius is limited to the new files. --- ## Design Decisions (Detailed) ### 1. Semantic Kernel with Azure OpenAI for LLM communication **What we chose:** Microsoft Semantic Kernel (SK) as the AI orchestration layer, connecting to Azure OpenAI via Azure AD authentication. **Why:** The core value isn't just chat — it's the **extraction agent loop**. The agent extracts trade data, calls validation tools, interprets results, retries with fixes, and escalates to the user. Without SK, you'd need to: (a) manually parse the LLM's tool-call JSON from the streaming response, (b) dispatch to the correct C# function, (c) serialize the result, (d) feed it back to the LLM, (e) handle the loop termination. SK does all of this with one line: `FunctionChoiceBehavior.Auto()`. It turns ~200 lines of manual orchestration into zero. Azure OpenAI is the LLM backend because CRC's sandbox environment provides it with an Azure AD tenant. `DefaultAzureCredential` integrates with CRC's existing Azure AD auth — no separate API keys to manage. On a developer's machine it uses the `az login` token; in production it can use managed identity. **What we rejected:** - **Raw HttpClient + manual SSE parsing** — This was the original Phase 2 approach. It works for simple chat but doesn't support tool calling without writing a full agent loop. Rejected when we added extraction tools. - **LangChain/.NET equivalent** — Considered briefly. SK is Microsoft's official offering, has first-class .NET support, and integrates cleanly with ASP.NET Core DI. LangChain's .NET port was less mature. - **OpenAI direct (non-Azure)** — CRC's network may not allow direct OpenAI access. Azure OpenAI is within the corporate Azure tenant, which is already permitted. - **API key auth** — Simpler to configure but keys need rotation and secure storage. Azure AD tokens are automatic and tied to the developer/service identity. **When you'd revisit this:** If the Azure OpenAI resource is decommissioned or you need a different model provider, swap `AddAzureOpenAIChatCompletion()` for `AddOpenAIChatCompletion()` — SK abstracts the difference. Everything downstream (controller, plugins, streaming) stays identical. **Target adaptation:** CRC uses Scrutor for assembly scanning. SK's `AddKernel()` and `AddAzureOpenAIChatCompletion()` are explicit registrations that coexist with Scrutor — no conflict. But verify that Scrutor doesn't auto-register ExtractionPlugin before your manual `AddScoped()` call (it could if it scans the Plugins namespace). If it does, you'll get the plugin registered without its HttpClient dependencies. Check by looking at CRC's Scrutor scan filters. **Azure AD prerequisite:** Developers must run `az login --tenant ` before starting CRC.Server. `DefaultAzureCredential` will silently fail at the first LLM call (not at startup) if the token isn't available — the error message mentions "ManagedIdentityCredential" and "EnvironmentCredential" failures, which can be confusing. The fix is always `az login`. --- ### 2. SSE streaming over WebSocket **What we chose:** Server-Sent Events (SSE) via `text/event-stream` response type. **Why:** SSE is unidirectional (server → client), matches the OpenAI API's native streaming format, and works through HTTP proxies and load balancers without special configuration. The client only sends complete requests via POST; the streaming is server-to-client only. WebSocket would add: connection upgrade negotiation, keep-alive pings, reconnection logic, and potential issues with CRC's reverse proxy/load balancer configuration. **What we rejected:** - **WebSocket** — Bidirectional, but we don't need client→server streaming. Adds complexity for no benefit. Also, CRC's deployment may use a reverse proxy that requires WebSocket upgrade configuration. - **Long polling** — Simpler but creates discrete request/response cycles. The user wouldn't see tokens appearing smoothly. - **SignalR** — Built on top of WebSocket with fallback. Overkill for this use case and adds a significant dependency. **When you'd revisit this:** If you later need real-time bidirectional features (e.g., server pushing extraction status updates while the user types), WebSocket or SignalR would make sense. For the current request→stream pattern, SSE is optimal. **Target adaptation:** Verify CRC's CORS policy allows `text/event-stream` content type. Some CORS configurations only allow `application/json`. Also verify the reverse proxy (if CRC uses one in production) doesn't buffer SSE responses — NGINX, for example, needs `proxy_buffering off` for SSE to work. --- ### 3. Typed HttpClient per external API **What we chose:** Three separate typed HttpClient services (`CounterpartyApiClient`, `TradeApiClient`, `CurrencyApiClient`), each registered with `AddHttpClient()`. **Why:** Each external API has a different base URL and potentially different auth/headers. Typed clients give each one its own HttpClient with pre-configured settings. `IHttpClientFactory` under the hood manages socket lifetimes (avoids DNS issues and socket exhaustion). Each client has a single async method, making them trivially mockable for testing. **What we rejected:** - **Single shared HttpClient** — Would need URL switching logic and couldn't have per-API base URLs. - **Named HttpClients** — `AddHttpClient("counterparty")` works but loses type safety. With typed clients, the compiler catches wrong-client injections. - **Direct HttpClient construction** — Bypasses IHttpClientFactory, risks socket exhaustion in long-running servers. **When you'd revisit this:** If CRC already has service clients for these external APIs (it likely does — CRC.Service handles trade querying and pricing), **use the existing ones** instead of creating new typed clients. The ExtractionPlugin would take CRC's existing service interfaces instead. This is the most likely adaptation point. **Target adaptation:** CRC already integrates with trade and pricing APIs via `CRC.Service`. Before creating new typed HttpClients, check if: - CRC.Service already has a counterparty lookup service - CRC.Service already has a trade validation service - CRC.Service already has currency validation If yes, inject those into ExtractionPlugin instead of creating new clients. The plugin methods still return JSON strings, but internally they call CRC's existing services. This is the **highest-value adaptation** because it reuses CRC's existing auth, error handling, and connection management. --- ### 4. Per-request plugin import (not global registration) **What we chose:** Import ExtractionPlugin into the Kernel per HTTP request, not at startup. **Why:** ExtractionPlugin depends on typed HttpClients (CounterpartyApiClient, etc.), which are transient/scoped services. If you import the plugin into the Kernel at startup (singleton), it captures the initial HttpClient instances. On subsequent requests, those instances may be stale or disposed. Per-request import resolves a fresh ExtractionPlugin from DI each time, with fresh HttpClient instances. **What we rejected:** - **Global plugin registration in Program.cs** — Would capture stale scoped dependencies. - **Making ExtractionPlugin a singleton** — Would prevent it from depending on scoped services. **When you'd revisit this:** If ExtractionPlugin had no scoped dependencies (e.g., all its validation was local, no HTTP calls), global registration would be fine and slightly more efficient. **Target adaptation:** If CRC's existing services are scoped (which is typical for services that touch databases or HTTP), the per-request import pattern is still required. Do not "optimize" this into a global registration. --- ### 5. FewShotService as singleton with clone-on-use **What we chose:** Load examples from disk once at startup, cache the assembled ChatHistory prefix, clone per request. **Why:** The few-shot examples (3 email/JSON pairs, ~10KB total) and instruction template don't change at runtime. Loading from disk on every request would add ~5ms of IO latency per extraction. The singleton loads once, and `ClonePrefix()` is a fast shallow copy (ChatHistory messages are immutable). **What we rejected:** - **Reload on every request** — Unnecessary IO for static data. - **Embed examples as C# constants** — Would make it impossible to update examples without recompiling. Disk files can be edited and the service restarts. - **Database storage** — Overkill for 3 examples. Files are simpler and human-editable. **When you'd revisit this:** If you want to A/B test different prompt configurations without restarting the server, you'd add a reload mechanism (e.g., `IOptionsMonitor` pattern or a manual reload endpoint). **Target adaptation:** The examples path is resolved relative to `ContentRootPath`. In CRC, verify where `ContentRootPath` points — it's typically the CRC.Server project root when running locally, but may differ in deployed environments. The path is configurable via `NlxvaPricer:FewShotPath` in appsettings.json, so you can point it anywhere. Make sure the examples folder is included in CRC.Server's publish output if deploying. --- ### 6. Component-local state (no Fluxor) **What we chose:** All state lives as private fields in the NlxvaPricer.razor component. **Why:** This is a self-contained page. The conversation, streaming state, extraction mode, and settings don't need to be shared with other pages. Adding Fluxor would require actions, reducers, and effects for what is essentially a `List` and a few booleans. The complexity cost outweighs the benefit. **What we rejected:** - **Fluxor state management** — CRC uses Fluxor everywhere else, but this feature has no cross-page state needs. Forcing Fluxor would mean ~200 lines of boilerplate (action classes, reducer methods, effect handlers) for zero architectural benefit. **When you'd revisit this:** If a future feature needs to share conversation state across pages (e.g., a conversation sidebar that persists while the user navigates to Pricer), migrate to Fluxor then. For now, YAGNI. **Target adaptation:** If CRC's code reviewers expect Fluxor for all state, discuss with them first. The argument: Fluxor adds value when state is shared across components or needs to survive navigation. This page's state is ephemeral (lost on refresh by design) and local. --- ### 7. Markdown sanitization via allowlist (not blocklist) **What we chose:** A strict tag/attribute allowlist that strips everything not explicitly permitted. **Why:** The LLM's output is **untrusted**. A sufficiently creative prompt injection could make the LLM emit `")` returns empty string (script stripped) --- ### T9: Add NlxvaPricerApiClient **Prerequisites:** T2 (DTOs for request/response types). **Context:** This is the client-side service that talks to the NlxvaPricerController. The critical pattern is SSE streaming in Blazor WASM — this is where the most subtle bugs occur. **Step-by-step:** 1. Create `CRC.Client/Services/NlxvaPricerApiClient.cs` 2. Register with `AddHttpClient()` pointing to CRC.Server's URL 3. Implement streaming methods using the exact pattern from export-spec Critical Pattern #1 **Expected friction on target:** - **SetBrowserResponseStreamingEnabled**: This extension method is from `Microsoft.AspNetCore.Components.WebAssembly.Http`. If CRC.Client doesn't already reference this namespace, add a `@using` or `using` directive. - **Blazor WASM HttpClient behavior**: In WASM, HttpClient is backed by the browser's Fetch API. The `SetBrowserResponseStreamingEnabled(true)` flag is essential — without it, the browser buffers the entire response and streaming won't work (the user sees nothing until the full response completes, then everything at once). **Verify it works:** - In browser dev tools (Network tab): the request to `/api/nlxva-pricer/chat` should show as `EventStream` type with chunks arriving over time, not a single response. **If it breaks — diagnostic checklist:** - Symptom: No tokens appear during streaming; entire response appears at once after completion Cause: `SetBrowserResponseStreamingEnabled(true)` missing Fix: Add to the HttpRequestMessage before sending - Symptom: `NotSupportedException: Synchronous operations are not allowed` Cause: Using `reader.EndOfStream` (performs sync read) Fix: Replace with `while ((line = await reader.ReadLineAsync()) != null)` loop - Symptom: `yield return` inside `try` block causes compiler error Cause: C# language restriction — `yield` cannot appear inside `try-catch` Fix: Parse into local variables inside `try`, yield outside (see Critical Pattern #7 in export-spec) --- ### T10: Add file-drop.js **Prerequisites:** None (standalone JS file). **Context:** Blazor WASM's built-in drag-and-drop support is limited. This JS file handles browser drag/drop events and calls back to .NET. **Step-by-step:** 1. Create `CRC.Client/wwwroot/js/file-drop.js` 2. Add `` to CRC.Client's `index.html` 3. Place the script tag BEFORE the Blazor framework script (`_framework/blazor.webassembly.js`) **Expected friction on target:** - **Script loading order**: If placed after the Blazor script, `window.fileDrop` may not be defined when the component tries to call it. Place before. - **Existing JS in CRC**: If CRC already bundles JS, consider whether to integrate with their bundling approach or keep as a standalone file. **Verify it works:** - Browser console: `window.fileDrop` should be defined (type `fileDrop` in console) --- ### T11: Add NlxvaPricer.razor page **Prerequisites:** T8 (MarkdownService), T9 (ApiClient), T10 (file-drop.js). **Context:** This is the main UI component — the largest single file. It brings together chat, streaming, markdown rendering, email upload, extraction mode, and prompt settings. **Step-by-step:** 1. Create `CRC.Client/Pages/NlxvaPricer.razor` 2. Route: `@page "/nlxva-pricer"` 3. Page title: `NL XVA Pricer` 4. Inject: `IJSRuntime`, `NlxvaPricerApiClient`, `MarkdownService` 5. Build the component following the export-spec structure **Expected friction on target:** - **MudBlazor version differences**: If CRC uses MudBlazor 6.x or 7.x, some component APIs differ (see MudBlazor Version Warning in Source → Target Mapping). - **CSS calc height**: MUST use `calc(100vh - 64px)` for CRC's regular-height AppBar. The source uses `48px` (Dense AppBar). Getting this wrong means the chat area either overflows below the viewport or leaves a gap. - **MudChip generic parameter**: In MudBlazor 9.x, `MudChip` requires `T="string"`. In older versions, it doesn't. Check CRC's version. - **KeepPanelsAlive**: If CRC's MudBlazor version doesn't support this prop, tab content will reset when switching tabs. Workaround: store prompt text and settings in `@code` fields (which we already do — the issue is that the MudTextField would lose focus/cursor position). **Verify it works:** - Navigate to `/nlxva-pricer` - Type a message and press Enter → should see streaming tokens - Switch to System Prompt tab, edit prompt, switch back → prompt should be preserved - Drop an `.html` email file → should enter extraction mode - Click New Chat → should reset everything --- ### T12: Add NlxvaPricer.razor.css **Prerequisites:** T11 (the page must exist for scoped CSS to apply). **Context:** Blazor CSS isolation scopes these styles to the NlxvaPricer component only. The `::deep` combinator is needed for styles targeting MudBlazor child markup. **Key adaptation:** - Change `calc(100vh - 48px)` to `calc(100vh - 64px)` for CRC's AppBar height - If CRC uses `100dvh` elsewhere, prefer that over `100vh` **Verify it works:** - The chat area should fill the viewport between the AppBar and the bottom of the page - Messages should scroll within the message-list area - No horizontal overflow on code blocks --- ### T13: Add NavMenu link **Prerequisites:** T11 (page must exist to navigate to). **Context:** Adding a single MudNavLink to CRC's existing NavMenu. **Step-by-step:** 1. Find CRC's NavMenu component (likely `CRC.Client/Shared/NavMenu.razor` or `CRC.Client/Layout/NavMenu.razor`) 2. Add the MudNavLink at the end of the existing list 3. Do NOT rearrange or modify existing links **Expected friction on target:** - **NavMenu structure**: CRC may use a different NavMenu component structure than what we have in ChatAgent. Look at how existing links are defined and follow the same pattern. **Verify it works:** - The "NL XVA Pricer" link appears in the sidebar - Clicking it navigates to `/nlxva-pricer` without a full page reload --- ### T14: Configuration **Prerequisites:** All server tasks complete. **Step-by-step checklist:** - [ ] `NlxvaPricer:AzureOpenAIEndpoint` in CRC.Server `appsettings.json` — e.g., `https://your-resource.openai.azure.com/` — **no `/v1`**. What happens if missing: SK registration fails at startup - [ ] `NlxvaPricer:DeploymentName` in CRC.Server `appsettings.json` — the Azure deployment name (not model name). Get from Azure portal → Azure OpenAI → Deployments - [ ] `NlxvaPricer:TenantId` in CRC.Server `appsettings.json` — Azure AD tenant ID. Same tenant CRC uses for Microsoft.Identity.Web auth - [ ] `NlxvaPricer:FewShotPath` in CRC.Server `appsettings.json` — default `examples/extraction` - [ ] Developer has run `az login --tenant ` — `DefaultAzureCredential` needs this. Failure shows at first LLM call, not at startup - [ ] `ExternalApis:CounterpartyBaseUrl` — default `http://localhost:5000/api/counterparty` (or use CRC's existing) - [ ] `ExternalApis:TradeBaseUrl` — default `http://localhost:5000/api/trade` (or use CRC's existing) - [ ] `ExternalApis:CurrencyBaseUrl` — default `http://localhost:5000/api/currency` (or use CRC's existing) - [ ] CRC.Client `appsettings.json` or `wwwroot/appsettings.json` — API base URL - [ ] CORS policy in CRC.Server — verify CRC.Client origin is allowed - [ ] `examples/` folder exists at configured path with `instruction-template.txt` and `few-shot/` subdirectories --- ### T15: Smoke test **Full verification sequence:** 1. `dotnet build --configuration release CRC.sln` — 0 errors, 0 new warnings 2. Ensure developer has run `az login --tenant ` 3. Start CRC.Server 4. Navigate to CRC.Client in browser 5. Verify "NL XVA Pricer" appears in sidebar 6. Click it → should navigate to `/nlxva-pricer` 7. Type "Hello" → should see streaming response 8. Switch to System Prompt tab → should see default prompt 9. Switch to Model Settings tab → should see Temperature/TopP/MaxTokens fields 10. Switch back to Chat → conversation should still be there (KeepPanelsAlive) 11. Drop an example email .html → should enter Extraction Mode, see streaming extraction 12. If extraction produces counterparty disambiguation → respond with a number → should route to extract endpoint 13. Click New Chat → everything resets, Extraction Mode indicator gone --- ## Troubleshooting Reference | # | Symptom | Likely Cause | Fix | |---|---|---|---| | 1 | 404 on Azure OpenAI endpoint | Wrong deployment name or endpoint URL | Verify deployment name in Azure portal; endpoint should be `https://.openai.azure.com/` with NO `/v1` | | 2 | CORS 403 in browser console | CORS policy doesn't cover CRC.Client origin or `text/event-stream` | Add CRC.Client origin with `AllowAnyHeader()` in CORS config | | 3 | No streaming — entire response at once | `SetBrowserResponseStreamingEnabled(true)` missing on client | Add to HttpRequestMessage before SendAsync | | 4 | `NotSupportedException: Synchronous operations` | Using `reader.EndOfStream` in WASM | Replace with `while ((line = await ReadLineAsync()) != null)` | | 5 | LLM never calls validation tools | Plugin not imported, or `FunctionChoiceBehavior.Auto()` not set | Import plugin per-request in controller action; set Auto() in settings | | 6 | `InvalidOperationException: Unable to resolve ExtractionPlugin` | Not registered in DI, or lifetime mismatch | Add `AddScoped()` to DI; verify dependencies | | 7 | Markdown not rendering (raw text) | MarkdownService not registered or not injected | Add `AddSingleton()` to client DI | | 8 | XSS in rendered content | Sanitizer not applied, or using raw `MarkupString` without sanitization | Ensure ConvertToHtml is called (includes sanitization) before MarkupString | | 9 | CSS overflow — chat extends below viewport | Wrong AppBar height in calc (48px vs 64px) | Change `calc(100vh - 48px)` to `calc(100vh - 64px)` | | 10 | Tab content resets on switch | MudBlazor version missing `KeepPanelsAlive` | Upgrade MudBlazor or use state fields (already done in code pattern) | | 11 | `FileNotFoundException` for instruction-template.txt | Examples folder not at ContentRootPath | Log ContentRootPath; verify examples location; update FewShotPath config | | 12 | Empty few-shot examples (only system message) | Subdirectory structure wrong | Verify `examples/extraction/few-shot/01/input.html` exists | | 13 | `NuGet restore error` for SemanticKernel | Package not in GV Artifactory feed | Request mirroring or temporary nuget.org source | | 14 | `CredentialUnavailableException` from DefaultAzureCredential | Developer not logged in via Azure CLI | Run `az login --tenant `, restart CRC.Server | | 14b | HTTP 403 from Azure OpenAI | Azure AD identity lacks role | Grant "Cognitive Services OpenAI User" on the Azure OpenAI resource | | 14c | All tokens arrive at once (no streaming) | Response compression or proxy buffering | Use stream-test diagnostic endpoint; check `UseResponseCompression()`; set `X-Accel-Buffering: no` header | | 14d | Streaming works in curl but not in browser | Response compression only applied for browser Accept-Encoding | Add `Response.Headers["Content-Encoding"] = "identity"` in SSE endpoints | | 15 | Drag-drop file not triggering extraction | `file-drop.js` not loaded | Check `