Replace CLIProxyAPI/local proxy references with Azure OpenAI using DefaultAzureCredential and tenant ID auth. Add Critical Pattern #8 for SSE buffering diagnostics with timestamped curl test. Add streaming verification tasks (T6b, T15) and troubleshooting entries for Azure AD auth, RBAC, response compression, and proxy buffering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
51 KiB
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:
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.nlxva-pricer-openspec.md— The OpenSpec bundle (proposal, design, tasks). Feed this to/opsx:applyon the target.- 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<ExtractionPlugin>() 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 <tenant-id> 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<T>().
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<T> 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<Message> 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 <script> tags, <img onerror="...">, or CSS expression() attacks. A blocklist ("strip <script> tags") will always miss edge cases. An allowlist ("only allow these 20 tags") is closed by default — anything unknown is stripped.
What we rejected:
- Blocklist approach — Always incomplete. New attack vectors appear regularly.
- No sanitization (trust Markdig) — Markdig is a parser, not a sanitizer. It faithfully converts markdown to HTML, including any raw HTML in the input.
- Third-party sanitizer library — HtmlSanitizer NuGet package exists but adds a dependency for something that's ~40 lines of regex. The custom sanitizer is simpler to audit.
When you'd revisit this: If you need to allow richer HTML (images, iframes for embedded content), extend the allowlist carefully rather than switching to a blocklist.
Target adaptation: If CRC already has an HTML sanitizer (check CRC.Component for reusable utilities), use theirs instead of creating MarkdownService from scratch.
Source → Target Mapping
| Source (ChatAgent) | Target (CRC) | Notes |
|---|---|---|
ChatAgent.Api/ |
CRC.Server/ |
CRC uses hosted Blazor model; server serves both API and client |
ChatAgent.Client/ |
CRC.Client/ |
Same WASM model |
ChatAgent.Shared/ |
CRC.Shared/ |
DTOs go here — follow CRC naming (*Dto, *Request, *Response) |
Program.cs (standalone) |
Program.cs or Startup.cs |
CRC may use Startup.cs pattern. Add DI registrations there. |
builder.Services.AddScoped<T>() |
Check Scrutor scan filters | Scrutor may auto-register; verify no duplicate |
appsettings.json |
appsettings.json (secondary config) |
CRC's primary config is gv_web_config.csv. Use appsettings for LLM/API URLs |
AddCors() |
Existing CORS policy | CRC already has CORS for its Client. Verify it covers new endpoints |
[ApiController] on controllers |
Same pattern | CRC uses [ApiController] on controllers |
AddHttpClient<T>() |
Same pattern OR use existing CRC.Service clients | Key decision point — see Design Decision #3 |
| MudBlazor 9.2.0 | CRC's MudBlazor version | Check version. API differences between MudBlazor 6.x and 9.x are significant |
AppBar Dense (48px) |
AppBar Regular (64px) |
CSS must use 64px, not 48px |
@page "/sales-assistant" |
@page "/nlxva-pricer" |
Route change |
ChatApiClient (typed) |
NlxvaPricerApiClient (typed) |
Follow CRC naming convention |
ILogger<T> (default) |
ILogger<T> via Serilog |
Same interface, CRC's Serilog handles sinks |
| No auth | [Authorize(Policy = Policy.ValidUser)] |
CRC uses policy-based auth — add attribute if required |
public partial class Program { } |
Check if CRC already has this | Needed for WebApplicationFactory in tests |
.NET 9 |
.NET 8 |
Verify all NuGet packages target .NET 8+ |
MudBlazor Version Warning
CRC's MudBlazor version is critical. The source uses MudBlazor 9.2.0. If CRC uses an earlier version (6.x or 7.x), several APIs differ:
MudChiprequiresT="string"in 9.x but not in 6.xMudNumericFieldgeneric syntax may differMudTabsKeepPanelsAlivemay not exist in older versions (use@bind-ActivePanelIndexworkaround)- Icons namespace:
Icons.Material.Filled.*is consistent across versions
Check CRC's MudBlazor version first: grep MudBlazor CRC.Client.csproj
Task-by-Task Implementation Notes
T1: Add NuGet packages
Prerequisites: Access to NuGet source (CRC uses internal GV Artifactory — nuget.org is disabled per CLAUDE.md).
Context: This task installs the two main dependencies. Everything else builds on these.
Step-by-step:
- Add
Microsoft.SemanticKerneltoCRC.Server.csproj - Add
Microsoft.SemanticKernel.Connectors.AzureOpenAItoCRC.Server.csproj - Add
Azure.IdentitytoCRC.Server.csproj(check first:grep -i Azure.Identity CRC.Server.csproj— CRC may already have it since it uses Azure AD) - Add
MarkdigtoCRC.Client.csproj(check if it's already there:grep -i markdig CRC.Client.csproj) - Run
dotnet restore CRC.sln
Expected friction on target:
- GV Artifactory may not have
Microsoft.SemanticKernel. SK is a relatively new package. If it's not mirrored in the internal feed, you'll need to either: request it be added to Artifactory, or temporarily add nuget.org as a source innuget.config(check with your team if this is allowed). - Azure.Identity version conflict. If CRC already has
Azure.Identityat a different version, the SK transitive dependency may conflict. Rundotnet list CRC.Server package --include-transitive | grep Azure.Identityto check. - Version pinning. CRC uses
RestorePackagesWithLockFile=true— after installing, commit the updatedpackages.lock.json.
Verify it works:
dotnet build --configuration release CRC.slnsucceeds with 0 errorsgrep SemanticKernel CRC.Server/obj/project.assets.jsonshows resolved package
If it breaks — diagnostic checklist:
- Symptom:
NU1100: Unable to resolve Microsoft.SemanticKernelCause: Package not available in GV Artifactory Fix: Request package mirroring, or add temporary nuget.org source - Symptom: Version conflict with existing OpenAI or Azure packages
Cause: SK pulls in OpenAI SDK transitive dependency
Fix: Check
dotnet list CRC.Server package --include-transitive | grep OpenAIand resolve version conflicts
T2: Add shared DTOs
Prerequisites: T1 (need System.Text.Json.Serialization from framework, but [JsonPropertyName] is built-in).
Context: These DTOs define the API contract between CRC.Client and CRC.Server. They live in CRC.Shared so both projects reference the same types. The export-spec has exact field definitions in the Contracts section.
Step-by-step:
- Create files in
CRC.Shared/Models/(or wherever CRC keeps its DTOs — check existing pattern) - Follow CRC naming: if CRC uses
TradeDtorather thanTradeItem, adapt. But keep[JsonPropertyName]values unchanged (these are the wire format for downstream systems). - Namespace: use
CRC.Shared.Models(orCRC.Shared.Dtosif that's CRC's convention)
Expected friction on target:
- CRC may already have a
TradeItemorTradeDtoclass in CRC.Shared. If so, DO NOT create a duplicate. Either extend the existing one (if fields are compatible) or use a separate name likeNlxvaTradeItem. - CRC naming conventions: DTOs use
*Dto,*Request,*Responsesuffixes. Adapt accordingly (e.g.,NlxvaChatRequestDtoinstead ofNlxvaChatRequest).
Verify it works:
dotnet build CRC.Sharedsucceeds- Both CRC.Server and CRC.Client can reference the new types
If it breaks — diagnostic checklist:
- Symptom: Namespace conflict
Cause: CRC.Shared already has a type with the same name
Fix: Prefix with
Nlxvaor put in a sub-namespaceCRC.Shared.Models.NlxvaPricer
T3: Add external API clients
Prerequisites: T2 (DTOs must exist for return types).
Context: These clients wrap external API calls that the extraction agent uses to validate extracted data. Each is a typed HttpClient with one async method.
Step-by-step:
- First, check if CRC already has these services. CRC.Service handles trade querying, pricing, and external API integration. Look for:
- Counterparty/legal entity lookup (probably exists — CRC deals with counterparties)
- Trade ID validation (probably exists — CRC queries trades via IBM MQ)
- Currency validation (may exist)
- If CRC has equivalents, skip this task and adapt ExtractionPlugin (T4) to use CRC's existing services. This is the recommended path.
- If CRC doesn't have equivalents, create the three typed clients in
CRC.Server/Services/and register them in DI.
Expected friction on target:
- CRC.Service already has these capabilities — this is almost certain. CRC.Service handles "trade querying, caching, IBM MQ/GVService client" per the project layout. Adapting the ExtractionPlugin to call CRC's existing interfaces (e.g.,
ITradeQueryService,ICounterpartyService) is cleaner than creating parallel clients. - DI registration: If creating new clients, use
AddHttpClient<T>()in CRC.Server's startup. If using existing CRC services, they're already registered.
Verify it works:
- If using existing CRC services: verify they can be injected into a new class
- If using new clients: write a quick integration test or use Swagger to hit the endpoints
If it breaks — diagnostic checklist:
- Symptom:
InvalidOperationException: Unable to resolve service for type CounterpartyApiClientCause: HttpClient not registered in DI Fix: Addbuilder.Services.AddHttpClient<CounterpartyApiClient>(...)to startup - Symptom: HTTP 401/403 from external API
Cause: CRC's external APIs may require auth headers
Fix: If using CRC's existing service clients, they handle auth. If using new clients, add auth headers in the
AddHttpClientconfiguration.
T4: Add ExtractionPlugin
Prerequisites: T3 (needs API clients or CRC services for injection).
Context: This is the core of the extraction feature. The plugin exposes 4 C# methods as "tools" that the LLM can call autonomously during the extraction conversation. The LLM reads an email, extracts trade data, then calls these tools to validate what it extracted. If validation fails, it fixes the extraction or asks the user for help.
Step-by-step:
- Create
CRC.Server/Plugins/ExtractionPlugin.cs(orCRC.Server/NlxvaPricer/Plugins/if you want to namespace it) - The constructor takes the API client services. If you're using CRC's existing services, adjust the constructor parameters.
- Each
[KernelFunction]method returns a serialized JSON string (not a C# object). This is critical — SK passes the return value as text to the LLM. - Register as Scoped:
builder.Services.AddScoped<ExtractionPlugin>()
Expected friction on target:
- Scrutor auto-registration: If CRC's Scrutor scan includes the
Pluginsnamespace, it may auto-register ExtractionPlugin as Transient (Scrutor's default). You need it Scoped (to match typed HttpClient lifetimes). Either: exclude it from Scrutor's scan, or register it explicitly and verify Scrutor doesn't override. [Description]attribute: This attribute (fromSystem.ComponentModel) tells the LLM what each tool does. The descriptions must be clear and specific — they're the LLM's only documentation for when to call each tool. Copy them from the export-spec exactly.
Verify it works:
- Unit test: instantiate ExtractionPlugin with mocked clients, call each method, verify JSON output shape
- The methods should be callable independently (they're pure functions over their inputs + HTTP calls)
If it breaks — diagnostic checklist:
- Symptom: LLM never calls the tools (just generates text without validation)
Cause: Plugin not imported into the Kernel, or
FunctionChoiceBehavior.Auto()not set Fix: Verify_kernel.ImportPluginFromObject(plugin, "Extraction")is called in the controller action, not in the constructor - Symptom:
System.InvalidOperationExceptionwhen resolving ExtractionPlugin Cause: Scoped/transient lifetime mismatch with dependencies Fix: Ensure ExtractionPlugin is Scoped and its dependencies (typed HttpClients) are Transient or Scoped - Symptom: LLM calls tools but gets empty/null results Cause: External API returning unexpected format Fix: Add logging in each plugin method to capture the raw API response before parsing
T5: Add FewShotService
Prerequisites: None (independent of API clients). But copy the examples/ folder first.
Context: The few-shot service loads real email → expected JSON examples from disk and pre-assembles them as a conversation prefix. This teaches the LLM the extraction format by demonstration. Without few-shot examples, the LLM would need to infer the output format from the instruction template alone — which works but produces more format errors.
Step-by-step:
- Copy the
examples/extraction/folder to the CRC.Server project root - Create
CRC.Server/Services/FewShotService.cs - Register as Singleton in DI
- Ensure the examples path is configurable via
NlxvaPricer:FewShotPath - Make sure examples are included in publish output (add to
.csprojif needed):<ItemGroup> <Content Include="examples/**" CopyToPublishDirectory="PreserveNewest" /> </ItemGroup>
Expected friction on target:
- ContentRootPath differences: When running via
dotnet run, ContentRootPath is the project directory. In IIS or Docker deployment, it may be different. The path resolution code (Path.IsPathRooted check + Combine with ContentRootPath) handles this, but verify in CRC's deployment environment. - File permissions: The examples folder needs read access at runtime. In a containerized deployment, ensure the folder is included in the Docker image.
Verify it works:
- At startup, check logs for "FewShotService loaded N examples" (add a log line if not present)
fewShotService.PrefixMessageCountshould be 7 (1 system + 3 examples × 2 messages each)
If it breaks — diagnostic checklist:
- Symptom:
FileNotFoundException: Could not find file 'examples/extraction/instruction-template.txt'Cause: Examples folder not copied to correct location, or ContentRootPath doesn't point where you expect Fix: Logbuilder.Environment.ContentRootPathat startup, verify examples are there - Symptom: 0 examples loaded (PrefixMessageCount = 1, only system message)
Cause:
few-shot/subdirectories not found, or files named differently Fix: Verify directory structure matches:examples/extraction/few-shot/01/input.html+output.json
T6: Register Semantic Kernel with Azure OpenAI
Prerequisites: T1 (NuGet packages installed). Developer has run az login --tenant <tenant-id>.
Context: This registers the SK Kernel and Azure OpenAI chat completion connector in DI. Unlike the source project (which used a local proxy), the CRC sandbox uses Azure OpenAI with Azure AD authentication. The key differences: use AddAzureOpenAIChatCompletion() (not AddOpenAIChatCompletion()), use deployment name (not model name), endpoint has NO /v1 suffix, and auth uses DefaultAzureCredential with the tenant ID.
Step-by-step:
- Add
using Microsoft.SemanticKernel;andusing Azure.Identity;to the startup file - Read config values from
NlxvaPricer:*section (AzureOpenAIEndpoint, DeploymentName, TenantId) - Register:
AddAzureOpenAIChatCompletion()thenAddKernel() - The endpoint is the Azure resource URL — do NOT add
/v1(the Azure SDK handles path construction) - Use
DefaultAzureCredentialwith the tenant ID
var azureEndpoint = builder.Configuration["NlxvaPricer:AzureOpenAIEndpoint"];
var deploymentName = builder.Configuration["NlxvaPricer:DeploymentName"];
var tenantId = builder.Configuration["NlxvaPricer:TenantId"];
builder.Services.AddAzureOpenAIChatCompletion(
deploymentName: deploymentName,
endpoint: azureEndpoint,
credentials: new DefaultAzureCredential(
new DefaultAzureCredentialOptions { TenantId = tenantId }));
builder.Services.AddKernel();
Expected friction on target:
az loginnot done:DefaultAzureCredentialtries multiple auth methods in sequence (environment vars → managed identity → Visual Studio → Azure CLI → etc.). On a developer machine, it relies on Azure CLI. If the developer hasn't runaz login --tenant <tenant-id>, the error at runtime will be a confusingCredentialUnavailableExceptionlisting all the methods it tried. The fix is always:az login --tenant <tenant-id>.- Deployment name vs model name: In Azure portal, you deploy a model (e.g.,
gpt-4o) and give the deployment a name (e.g.,gpt4o-prod). You pass the deployment name to SK, not the model name. Ask your Azure admin for the deployment name. - Azure RBAC permissions: The developer's Azure AD identity needs the "Cognitive Services OpenAI User" role on the Azure OpenAI resource. Without it, you'll get a 403.
Verify it works:
dotnet buildsucceeds- At runtime: inject
Kernelinto a test controller and verify it resolves - Quick smoke test:
kernel.GetRequiredService<IChatCompletionService>()— should not throw - Full test: the diagnostic stream-test endpoint (see T6b below)
If it breaks — diagnostic checklist:
- Symptom:
CredentialUnavailableExceptionwith "DefaultAzureCredential failed to retrieve a token" Cause: Developer not logged in to Azure CLI Fix: Runaz login --tenant <tenant-id>, then restart CRC.Server - Symptom: HTTP 403 Forbidden from Azure OpenAI Cause: Azure AD identity lacks "Cognitive Services OpenAI User" role Fix: Ask Azure admin to grant the role on the Azure OpenAI resource
- Symptom: HTTP 404 on Azure OpenAI endpoint Cause: Wrong deployment name, or deployment doesn't exist Fix: Verify deployment name in Azure portal → Azure OpenAI → Deployments
- Symptom:
InvalidOperationException: No service for type IChatCompletionServiceCause:AddAzureOpenAIChatCompletion()not called beforeAddKernel()Fix: Ensure registration order: AzureOpenAIChatCompletion first, then Kernel
T6b: Verify streaming hop 1 (Azure OpenAI → CRC.Server)
Prerequisites: T6 (SK registered), T7 (controller exists — or add the diagnostic endpoint to any controller temporarily).
Context: Before building the full UI, verify that tokens actually stream from Azure OpenAI through CRC.Server. This catches buffering issues early (response compression middleware, Azure API Management, corporate proxies).
Step-by-step:
- Add a temporary diagnostic endpoint to NlxvaPricerController (see Critical Pattern #8 in export-spec)
- Run:
curl -N https://localhost:7100/api/nlxva-pricer/stream-test - Watch the timestamps in the output
What correct streaming looks like:
data: [450ms] 1 ← timestamps spread across seconds
data: [620ms]
data: [780ms] 2
data: [950ms]
data: [1100ms] 3
What buffered streaming looks like:
data: [8200ms] 1 ← all timestamps clustered at the end
data: [8201ms]
data: [8202ms] 2
data: [8203ms]
If buffered — check these in order:
- Response compression middleware: If CRC.Server has
app.UseResponseCompression(), it buffers SSE to compress. AddResponse.Headers["Content-Encoding"] = "identity";in the controller to opt out. - Azure API Management (APIM): If APIM sits in front of the Azure OpenAI resource, it buffers by default. Need
forward-requestpolicy withbuffer-response="false". - Corporate HTTPS proxy: Check
echo $HTTPS_PROXYon the server. May need proxy bypass for*.openai.azure.com. - IIS: If CRC runs under IIS, add
responseBufferLimit="0"in web.config.
Always set these headers on SSE endpoints:
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no"; // prevents NGINX buffering
- Remove the diagnostic endpoint after verification.
T7: Add NlxvaPricerController
Prerequisites: T2 (DTOs), T4 (ExtractionPlugin), T5 (FewShotService), T6 (SK Kernel).
Context: This is the main API surface. Two endpoints, same SSE streaming pattern. The controller is intentionally stateless — all state is per-request.
Step-by-step:
- Create
CRC.Server/Controllers/NlxvaPricerController.cs - Route:
[Route("api/nlxva-pricer")] - Inject
Kernelvia constructor - In each action: resolve ExtractionPlugin from
HttpContext.RequestServices, import into Kernel - Stream SSE with exact format from export-spec
Expected friction on target:
- CRC controller conventions: Check if CRC controllers follow additional patterns (base class? custom action filters? logging middleware?). Follow the same pattern.
- Authorization: CRC uses
[Authorize(Policy = Policy.ValidUser)]. Decide whether the NL XVA Pricer should require auth (probably yes in production, maybe not in development). Ask the team. - CORS: The SSE response type (
text/event-stream) must be allowed by CRC's CORS policy. If CRC's CORS only allowsapplication/json, you'll get a CORS error on the streaming response.
Verify it works:
curl -X POST http://localhost:7100/api/nlxva-pricer/chat -H "Content-Type: application/json" -d '{"messages":[{"role":"user","content":"hello"}]}'should return SSE events- The response should have
Content-Type: text/event-stream
If it breaks — diagnostic checklist:
- Symptom: CORS error in browser console
Cause: CORS policy doesn't allow the CRC.Client origin for
text/event-streamFix: Update CORS policy in CRC.Server to allowAllowAnyHeader()or specificallytext/event-stream - Symptom: Tools called but response appears to hang
Cause:
Response.Body.FlushAsync()not called after each SSE write Fix: Ensure bothWriteAsyncandFlushAsyncare called after eachdata:line - Symptom: Empty response (no tokens)
Cause: SK's
GetStreamingChatMessageContentsAsyncreturns tool-call chunks (not text) that are being skipped, but no text chunks follow Fix: Check thatFunctionChoiceBehavior.Auto()is set — without it, SK returns raw tool-call JSON instead of executing tools
T8: Add MarkdownService
Prerequisites: T1 (Markdig package in CRC.Client).
Context: Converts LLM markdown output to sanitized HTML for safe browser rendering. The sanitization is critical — LLM output is untrusted content rendered via MarkupString.
Step-by-step:
- Check if CRC already has a markdown renderer (grep for
MarkdigorMarkupStringin CRC.Client) - If not, create
CRC.Client/Services/MarkdownService.cs - Register as Singleton
- The exact sanitization logic is in the export-spec
Expected friction on target:
- Existing sanitizer: CRC.Component may already have HTML sanitization utilities. If so, compose: use Markdig for md→html conversion, then CRC's sanitizer for the output.
- Regex compilation: The sanitizer uses
RegexOptions.Compiledfor performance. This is fine in long-running server processes but takes slightly longer on first call. No action needed.
Verify it works:
- Unit test:
markdownService.ConvertToHtml("**bold**")returns<p><strong>bold</strong></p> - XSS test:
markdownService.ConvertToHtml("<script>alert('xss')</script>")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:
- Create
CRC.Client/Services/NlxvaPricerApiClient.cs - Register with
AddHttpClient<NlxvaPricerApiClient>()pointing to CRC.Server's URL - 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@usingorusingdirective. - 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/chatshould show asEventStreamtype 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 allowedCause: Usingreader.EndOfStream(performs sync read) Fix: Replace withwhile ((line = await reader.ReadLineAsync()) != null)loop - Symptom:
yield returninsidetryblock causes compiler error Cause: C# language restriction —yieldcannot appear insidetry-catchFix: Parse into local variables insidetry, 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:
- Create
CRC.Client/wwwroot/js/file-drop.js - Add
<script src="js/file-drop.js"></script>to CRC.Client'sindex.html - 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.fileDropmay 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.fileDropshould be defined (typefileDropin 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:
- Create
CRC.Client/Pages/NlxvaPricer.razor - Route:
@page "/nlxva-pricer" - Page title:
<PageTitle>NL XVA Pricer</PageTitle> - Inject:
IJSRuntime,NlxvaPricerApiClient,MarkdownService - 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 uses48px(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,
MudChiprequiresT="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
@codefields (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
.htmlemail 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)tocalc(100vh - 64px)for CRC's AppBar height - If CRC uses
100dvhelsewhere, prefer that over100vh
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:
- Find CRC's NavMenu component (likely
CRC.Client/Shared/NavMenu.razororCRC.Client/Layout/NavMenu.razor) - Add the MudNavLink at the end of the existing list
- 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-pricerwithout a full page reload
T14: Configuration
Prerequisites: All server tasks complete.
Step-by-step checklist:
NlxvaPricer:AzureOpenAIEndpointin CRC.Serverappsettings.json— e.g.,https://your-resource.openai.azure.com/— no/v1. What happens if missing: SK registration fails at startupNlxvaPricer:DeploymentNamein CRC.Serverappsettings.json— the Azure deployment name (not model name). Get from Azure portal → Azure OpenAI → DeploymentsNlxvaPricer:TenantIdin CRC.Serverappsettings.json— Azure AD tenant ID. Same tenant CRC uses for Microsoft.Identity.Web authNlxvaPricer:FewShotPathin CRC.Serverappsettings.json— defaultexamples/extraction- Developer has run
az login --tenant <tenant-id>—DefaultAzureCredentialneeds this. Failure shows at first LLM call, not at startup ExternalApis:CounterpartyBaseUrl— defaulthttp://localhost:5000/api/counterparty(or use CRC's existing)ExternalApis:TradeBaseUrl— defaulthttp://localhost:5000/api/trade(or use CRC's existing)ExternalApis:CurrencyBaseUrl— defaulthttp://localhost:5000/api/currency(or use CRC's existing)- CRC.Client
appsettings.jsonorwwwroot/appsettings.json— API base URL - CORS policy in CRC.Server — verify CRC.Client origin is allowed
examples/folder exists at configured path withinstruction-template.txtandfew-shot/subdirectories
T15: Smoke test
Full verification sequence:
dotnet build --configuration release CRC.sln— 0 errors, 0 new warnings- Ensure developer has run
az login --tenant <tenant-id> - Start CRC.Server
- Navigate to CRC.Client in browser
- Verify "NL XVA Pricer" appears in sidebar
- Click it → should navigate to
/nlxva-pricer - Type "Hello" → should see streaming response
- Switch to System Prompt tab → should see default prompt
- Switch to Model Settings tab → should see Temperature/TopP/MaxTokens fields
- Switch back to Chat → conversation should still be there (KeepPanelsAlive)
- Drop an example email .html → should enter Extraction Mode, see streaming extraction
- If extraction produces counterparty disambiguation → respond with a number → should route to extract endpoint
- 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://<resource>.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<ExtractionPlugin>() to DI; verify dependencies |
| 7 | Markdown not rendering (raw text) | MarkdownService not registered or not injected | Add AddSingleton<MarkdownService>() 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 <tenant-id>, 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 <script> tag in index.html; check browser console for JS errors |
| 16 | window.fileDrop is undefined |
Script loaded after Blazor framework init | Move <script> tag before _framework/blazor.webassembly.js |
| 17 | JsonException when parsing SSE data |
SSE line doesn't match expected format | Add logging for raw SSE lines; check server-side WriteSSEAsync format |
Dependency & Package Notes
Microsoft.SemanticKernel
- Why needed: AI orchestration — chat completion, tool calling, auto function invocation
- .NET compatibility: Requires .NET 8+ (compatible with CRC)
- Transitive dependencies: Pulls in
Microsoft.Extensions.DependencyInjection,Microsoft.Extensions.Logging,OpenAISDK. Check for version conflicts with CRC's existing packages. - NuGet source: Available on nuget.org. If CRC's GV Artifactory doesn't mirror it, this is a blocker — request mirroring.
- Size: ~5MB total with dependencies
Microsoft.SemanticKernel.Connectors.AzureOpenAI
- Why needed: Azure OpenAI-specific connector for SK (provides
AddAzureOpenAIChatCompletion()) - .NET compatibility: Same as core SK package
- Transitive dependencies: Pulls in
Azure.AI.OpenAISDK - NuGet source: Same as core SK — nuget.org
- Note: This is separate from the core SK package. Without it, only
AddOpenAIChatCompletion()is available (for non-Azure endpoints).
Azure.Identity
- Why needed: Provides
DefaultAzureCredentialfor Azure AD authentication to Azure OpenAI - .NET compatibility: .NET Standard 2.0+ (compatible with everything)
- CRC likely already has this — it uses
Microsoft.Identity.Webfor Azure AD auth. Checkgrep Azure.Identity CRC.Server.csproj. - Version conflicts: If CRC has an older version, SK may pull in a newer one. Usually compatible, but verify with
dotnet build. - NuGet source: Available on nuget.org and commonly mirrored in enterprise feeds
Markdig (1.1.1)
- Why needed: Markdown → HTML conversion for rendering LLM responses
- .NET compatibility: .NET Standard 2.0+ (compatible with everything)
- Transitive dependencies: None
- NuGet source: Available on nuget.org and commonly mirrored
- Size: ~500KB
- Conflicts: None known
Rollback Plan
If the feature needs to be removed:
Files added (safe to delete):
CRC.Server/Controllers/NlxvaPricerController.csCRC.Server/Plugins/ExtractionPlugin.csCRC.Server/Services/FewShotService.csCRC.Server/Services/CounterpartyApiClient.cs(if created)CRC.Server/Services/TradeApiClient.cs(if created)CRC.Server/Services/CurrencyApiClient.cs(if created)CRC.Client/Pages/NlxvaPricer.razor+.razor.cssCRC.Client/Services/NlxvaPricerApiClient.csCRC.Client/Services/MarkdownService.csCRC.Client/wwwroot/js/file-drop.jsCRC.Shared/Models/Nlxva*.csfilesexamples/extraction/folder
Files modified (revert specific sections):
- CRC NavMenu: remove the single
<MudNavLink>for NL XVA Pricer - CRC.Client
index.html: remove<script src="js/file-drop.js">line - CRC.Server startup: remove SK, FewShotService, ExtractionPlugin, typed HttpClient registrations
- CRC.Server
appsettings.json: removeNlxvaPricerandExternalApissections - CRC.Client
Program.cs: remove NlxvaPricerApiClient and MarkdownService registrations
NuGet packages to remove:
Microsoft.SemanticKernelfrom CRC.ServerMicrosoft.SemanticKernel.Connectors.AzureOpenAIfrom CRC.ServerAzure.Identityfrom CRC.Server (only if not used by other CRC features — likely IS used, so leave it)Markdigfrom CRC.Client (if not used by other features)
Config keys to remove:
NlxvaPricer:*section (AzureOpenAIEndpoint, DeploymentName, TenantId, FewShotPath) fromappsettings.jsonExternalApis:*section (if only used by this feature)