feat: add porting-guide skill and NL XVA Pricer export bundle
Add /opsx:porting-guide skill that generates detailed human-readable implementation guides as a companion to /opsx:export-spec. The AI spec targets the agent; the porting guide targets the human developer with design rationale, task-by-task notes, troubleshooting tables, and rollback plans. Generate the full NL XVA Pricer export bundle for CRC: - nlxva-pricer-spec.md (AI-targeted portable spec) - nlxva-pricer-openspec.md (OpenSpec proposal/design/tasks) - nlxva-pricer-porting-guide.md (human implementation guide) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
196
openspec/exports/nlxva-pricer-openspec.md
Normal file
196
openspec/exports/nlxva-pricer-openspec.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Natural Language XVA Pricer — OpenSpec Bundle for CRC
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
On the CRC target machine:
|
||||
|
||||
1. **Create the change directory:**
|
||||
```
|
||||
mkdir -p openspec/changes/nlxva-pricer
|
||||
```
|
||||
|
||||
2. **Save these files** into `openspec/changes/nlxva-pricer/`:
|
||||
- `proposal.md` (below)
|
||||
- `design.md` (below)
|
||||
- `tasks.md` (below)
|
||||
- `.openspec.yaml` (below)
|
||||
|
||||
3. **Copy the portable spec** `nlxva-pricer-spec.md` somewhere accessible
|
||||
(e.g., `openspec/changes/nlxva-pricer/reference-spec.md`)
|
||||
|
||||
4. **Copy the examples folder** to CRC.Server project root:
|
||||
```
|
||||
examples/extraction/
|
||||
├── instruction-template.txt
|
||||
└── few-shot/01/ 02/ 03/ (each with input.html + output.json)
|
||||
```
|
||||
|
||||
5. **Run:** `/opsx:apply nlxva-pricer` — reference the portable spec when implementing each task
|
||||
|
||||
---
|
||||
|
||||
## .openspec.yaml
|
||||
|
||||
```yaml
|
||||
name: nlxva-pricer
|
||||
title: Natural Language XVA Pricer
|
||||
status: active
|
||||
created: 2026-04-07
|
||||
specs:
|
||||
- nlxva-pricer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## proposal.md
|
||||
|
||||
```markdown
|
||||
# Add Natural Language XVA Pricer
|
||||
|
||||
## What
|
||||
Add an AI-powered chat page to CRC that can:
|
||||
1. Accept natural language queries about XVA pricing
|
||||
2. Accept email uploads (.html) and autonomously extract structured trade data (TradeItem JSON)
|
||||
3. Validate extracted data via external API tool calls (counterparty lookup, trade/currency validation)
|
||||
4. Handle disambiguation (multiple counterparty matches) via conversational follow-up
|
||||
5. Stream responses token-by-token with markdown rendering
|
||||
|
||||
## Why
|
||||
Sales/CVA desk currently manually reads pricing request emails and re-types trade data.
|
||||
This feature automates extraction with AI + tool-calling validation, reducing errors and time.
|
||||
|
||||
## Scope
|
||||
- New page at /nlxva-pricer, new MudNavLink in existing NavMenu
|
||||
- New controller with 2 endpoints (chat + extract), same SSE streaming contract
|
||||
- Semantic Kernel integration with OpenAI-compatible proxy
|
||||
- Few-shot prompting infrastructure (instruction template + 3 examples)
|
||||
- External API clients for counterparty/trade/currency validation
|
||||
- Client-side markdown rendering with XSS sanitization
|
||||
- Email drag-and-drop and file picker upload
|
||||
- System prompt and model settings tabs for iteration
|
||||
|
||||
## Non-goals
|
||||
- No Fluxor state management (local component state is sufficient for this isolated page)
|
||||
- No conversation persistence (in-memory only, lost on refresh)
|
||||
- No auth changes (inherits CRC's existing auth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## design.md
|
||||
|
||||
```markdown
|
||||
# NL XVA Pricer — Design
|
||||
|
||||
## Architecture Decision: Semantic Kernel over raw HttpClient
|
||||
|
||||
**Why:** SK provides automatic function calling — the LLM can invoke validation tools
|
||||
(lookup_counterparty, validate_trade, etc.) autonomously and reason about results.
|
||||
With raw HttpClient we'd need to manually parse tool-call JSON, dispatch functions,
|
||||
and feed results back. SK handles this loop automatically via FunctionChoiceBehavior.Auto().
|
||||
|
||||
## Architecture Decision: SSE streaming over WebSocket
|
||||
|
||||
**Why:** SSE is simpler (unidirectional server→client), works through HTTP proxies,
|
||||
and matches the OpenAI API's native streaming format. WebSocket would add complexity
|
||||
(connection management, reconnection logic) with no benefit for this use case.
|
||||
The client only sends complete requests via POST; streaming is server→client only.
|
||||
|
||||
## Architecture Decision: Typed HttpClient per external API
|
||||
|
||||
**Why:** Each external API (counterparty, trade, currency) gets its own typed HttpClient
|
||||
registered via AddHttpClient<T>(). This gives each client its own base URL, keeps
|
||||
concerns separated, and makes testing easy (mock one client without affecting others).
|
||||
IHttpClientFactory manages socket lifetimes and avoids exhaustion.
|
||||
|
||||
## Architecture Decision: Per-request plugin import (not global registration)
|
||||
|
||||
**Why:** ExtractionPlugin depends on scoped services (typed HttpClients).
|
||||
Registering it globally on the Kernel at startup would capture stale references.
|
||||
Instead, we resolve from DI per-request and import into the Kernel.
|
||||
|
||||
## Architecture Decision: FewShotService as singleton with clone-on-use
|
||||
|
||||
**Why:** Loading examples from disk is IO-bound and slow. Do it once at startup,
|
||||
cache the assembled ChatHistory prefix, and clone per request. The clone is a
|
||||
shallow copy (ChatHistory messages are immutable value objects) so it's fast.
|
||||
|
||||
## Architecture Decision: Markdown render caching
|
||||
|
||||
**Why:** During streaming, StateHasChanged() fires on every token. Without caching,
|
||||
Markdig re-processes ALL prior messages each time. With a Dictionary<Message, string>
|
||||
cache, only the actively streaming message re-renders. Completed messages serve cached HTML.
|
||||
|
||||
## Architecture Decision: Component-local state (no Fluxor)
|
||||
|
||||
**Why:** This is a self-contained page with no cross-page state sharing needs.
|
||||
Adding Fluxor actions/reducers/effects would be overengineering. The conversation
|
||||
list, streaming flag, extraction mode, and settings all live as private fields
|
||||
in the Razor component. CRC's existing Fluxor infrastructure is untouched.
|
||||
|
||||
## Risk: CORS
|
||||
|
||||
CRC.Server may need its CORS policy updated to allow SSE streaming (Content-Type: text/event-stream)
|
||||
to the CRC.Client origin. Verify existing policy covers this.
|
||||
|
||||
## Risk: Semantic Kernel version compatibility
|
||||
|
||||
CRC targets .NET 8.0. Ensure the SK NuGet package version is compatible with .NET 8.
|
||||
Current stable SK packages support .NET 8+.
|
||||
|
||||
## Risk: Large file uploads
|
||||
|
||||
Email HTML files are read entirely into memory (max 10MB guard). For typical sales emails
|
||||
(< 100KB) this is fine. The guard prevents accidental large file uploads.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tasks.md
|
||||
|
||||
```markdown
|
||||
# NL XVA Pricer — Implementation Tasks
|
||||
|
||||
## Phase 1: Foundation (Server)
|
||||
|
||||
- [ ] **T1: Add NuGet packages** — Add `Microsoft.SemanticKernel` to CRC.Server. Add `Markdig` 1.1.1 to CRC.Client (if not already present). Verify .NET 8 compatibility.
|
||||
|
||||
- [ ] **T2: Add shared DTOs** — Create in CRC.Shared: `NlxvaChatMessage`, `NlxvaChatRequest`, `NlxvaModelSettings`, `NlxvaExtractionRequest`, `NlxvaExtractionResult`, `TradeItem` (with `[JsonPropertyName]` snake_case), `NlxvaValidationResult`, `NlxvaCandidateMatch`. See Contracts section in reference spec for exact shapes.
|
||||
|
||||
- [ ] **T3: Add external API clients** — Create in CRC.Server/Services: `CounterpartyApiClient`, `TradeApiClient`, `CurrencyApiClient`. Each is a typed HttpClient with a single async method. Register via `AddHttpClient<T>()` in Program.cs/Startup.cs with base URLs from appsettings.json `ExternalApis` section.
|
||||
|
||||
- [ ] **T4: Add ExtractionPlugin** — Create in CRC.Server/Plugins: `ExtractionPlugin` with 4 `[KernelFunction]` methods: `lookup_counterparty`, `validate_trade`, `validate_currency`, `validate_schema`. Each returns serialized JSON string. Register as Scoped in DI. See Critical Patterns #5 and #6 in reference spec.
|
||||
|
||||
- [ ] **T5: Add FewShotService** — Create in CRC.Server/Services: `FewShotService` that loads instruction template + few-shot examples from disk. Caches ChatHistory prefix. Methods: `CloneWithEmail()`, `CloneWithEmailAndMessages()`. Register as Singleton. Copy examples/ folder to CRC.Server root.
|
||||
|
||||
- [ ] **T6: Register Semantic Kernel** — In CRC.Server DI: `AddOpenAIChatCompletion()` + `AddKernel()`. Base URL MUST include `/v1`. Config from `NlxvaPricer:*` keys in appsettings.json. See Critical Pattern #2.
|
||||
|
||||
- [ ] **T7: Add NlxvaPricerController** — Create controller with `POST /api/nlxva-pricer/chat` and `POST /api/nlxva-pricer/extract`. Both stream SSE. Chat endpoint: builds ChatHistory from messages + optional system prompt + model settings. Extract endpoint: uses FewShotService prefix. Both import ExtractionPlugin per-request and enable `FunctionChoiceBehavior.Auto()`. See Critical Pattern #6.
|
||||
|
||||
## Phase 2: Client
|
||||
|
||||
- [ ] **T8: Add MarkdownService** — Create in CRC.Client/Services: `MarkdownService` with Markdig pipeline + HTML sanitization (tag/attribute allowlist). Register as Singleton.
|
||||
|
||||
- [ ] **T9: Add NlxvaPricerApiClient** — Create in CRC.Client/Services: typed HttpClient with `SendChatStreamingAsync()` and `SendExtractionStreamingAsync()`. MUST use `SetBrowserResponseStreamingEnabled(true)` + `HttpCompletionOption.ResponseHeadersRead` + ReadLineAsync loop (NOT EndOfStream). See Critical Pattern #1 and #7.
|
||||
|
||||
- [ ] **T10: Add file-drop.js** — Create `CRC.Client/wwwroot/js/file-drop.js`. Registers drag/drop handlers on a CSS-selector target, reads file as text, calls back to .NET via DotNetObjectReference. Add `<script>` reference in index.html.
|
||||
|
||||
- [ ] **T11: Add NlxvaPricer.razor page** — Create page at `@page "/nlxva-pricer"`. MudTabs with 3 panels (Chat, System Prompt, Model Settings). Message list with user/assistant bubbles, streaming indicator, markdown rendering. Email upload via drag-drop + InputFile. Extraction mode tracking and routing. See reference spec for full component behavior. CSS: use `calc(100vh - 64px)` for CRC's regular AppBar (NOT 48px). See Critical Pattern #3.
|
||||
|
||||
- [ ] **T12: Add NlxvaPricer.razor.css** — Scoped styles: tab-container, chat-container, message-list, message bubbles, input area, markdown-body styles, drag-over feedback, extraction indicator. All use `::deep` where targeting MudBlazor child markup.
|
||||
|
||||
- [ ] **T13: Add NavMenu link** — Add `<MudNavLink Href="/nlxva-pricer" Icon="@Icons.Material.Filled.SmartToy">NL XVA Pricer</MudNavLink>` to CRC's existing NavMenu component.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
- [ ] **T14: Config** — Add `NlxvaPricer` and `ExternalApis` sections to CRC.Server appsettings.json. Ensure CORS allows CRC.Client origin for SSE responses.
|
||||
|
||||
- [ ] **T15: Smoke test** — Build both projects. Navigate to /nlxva-pricer. Send a chat message → verify streaming. Upload an example email HTML → verify extraction streams. Verify New Chat resets. Verify drag-drop visual feedback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Reference the portable spec (`nlxva-pricer-spec.md`) for exact contracts, critical patterns, and CSS values
|
||||
- Adapt all new code to CRC naming conventions (E-prefix enums, I-prefix interfaces, *Dto suffixes)
|
||||
- Do NOT modify any existing CRC files except: NavMenu (add one link), index.html (add one script tag), Program.cs/Startup.cs (add DI registrations), appsettings.json (add config sections)
|
||||
- If any task conflicts with existing CRC patterns, STOP and consult the user
|
||||
```
|
||||
671
openspec/exports/nlxva-pricer-porting-guide.md
Normal file
671
openspec/exports/nlxva-pricer-porting-guide.md
Normal file
@@ -0,0 +1,671 @@
|
||||
# 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 an OpenAI-compatible LLM proxy (CLIProxyAPI running locally). 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) a CLIProxyAPI proxy for LLM access (any OpenAI-compatible endpoint works), (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 over raw HttpClient for LLM communication
|
||||
|
||||
**What we chose:** Microsoft Semantic Kernel (SK) as the AI orchestration layer.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
- **Azure OpenAI Service directly** — CRC's network may not allow direct Azure OpenAI access from the server. CLIProxyAPI acts as a local proxy, and SK's OpenAI connector targets any OpenAI-compatible endpoint.
|
||||
|
||||
**When you'd revisit this:** If CRC moves to Azure OpenAI with managed identity auth, you'd swap `AddOpenAIChatCompletion()` for `AddAzureOpenAIChatCompletion()`. SK makes this a one-line change.
|
||||
|
||||
**Target adaptation:** CRC uses Scrutor for assembly scanning. SK's `AddKernel()` and `AddOpenAIChatCompletion()` 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.
|
||||
|
||||
---
|
||||
|
||||
### 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:
|
||||
|
||||
- `MudChip` requires `T="string"` in 9.x but not in 6.x
|
||||
- `MudNumericField` generic syntax may differ
|
||||
- `MudTabs` `KeepPanelsAlive` may not exist in older versions (use `@bind-ActivePanelIndex` workaround)
|
||||
- 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:**
|
||||
1. Add `Microsoft.SemanticKernel` to `CRC.Server.csproj`
|
||||
2. Add `Markdig` to `CRC.Client.csproj` (check if it's already there: `grep -i markdig CRC.Client.csproj`)
|
||||
3. 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 in `nuget.config` (check with your team if this is allowed).
|
||||
- **Version pinning**. CRC uses `RestorePackagesWithLockFile=true` — after installing, commit the updated `packages.lock.json`.
|
||||
|
||||
**Verify it works:**
|
||||
- `dotnet build --configuration release CRC.sln` succeeds with 0 errors
|
||||
- `grep SemanticKernel CRC.Server/obj/project.assets.json` shows resolved package
|
||||
|
||||
**If it breaks — diagnostic checklist:**
|
||||
- Symptom: `NU1100: Unable to resolve Microsoft.SemanticKernel`
|
||||
Cause: 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 OpenAI` and 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:**
|
||||
1. Create files in `CRC.Shared/Models/` (or wherever CRC keeps its DTOs — check existing pattern)
|
||||
2. Follow CRC naming: if CRC uses `TradeDto` rather than `TradeItem`, adapt. But keep `[JsonPropertyName]` values unchanged (these are the wire format for downstream systems).
|
||||
3. Namespace: use `CRC.Shared.Models` (or `CRC.Shared.Dtos` if that's CRC's convention)
|
||||
|
||||
**Expected friction on target:**
|
||||
- **CRC may already have a `TradeItem` or `TradeDto` class** in CRC.Shared. If so, DO NOT create a duplicate. Either extend the existing one (if fields are compatible) or use a separate name like `NlxvaTradeItem`.
|
||||
- **CRC naming conventions**: DTOs use `*Dto`, `*Request`, `*Response` suffixes. Adapt accordingly (e.g., `NlxvaChatRequestDto` instead of `NlxvaChatRequest`).
|
||||
|
||||
**Verify it works:**
|
||||
- `dotnet build CRC.Shared` succeeds
|
||||
- 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 `Nlxva` or put in a sub-namespace `CRC.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:**
|
||||
1. **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)
|
||||
2. If CRC has equivalents, **skip this task** and adapt ExtractionPlugin (T4) to use CRC's existing services. This is the recommended path.
|
||||
3. 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 CounterpartyApiClient`
|
||||
Cause: HttpClient not registered in DI
|
||||
Fix: Add `builder.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 `AddHttpClient` configuration.
|
||||
|
||||
---
|
||||
|
||||
### 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:**
|
||||
1. Create `CRC.Server/Plugins/ExtractionPlugin.cs` (or `CRC.Server/NlxvaPricer/Plugins/` if you want to namespace it)
|
||||
2. The constructor takes the API client services. If you're using CRC's existing services, adjust the constructor parameters.
|
||||
3. 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.
|
||||
4. Register as Scoped: `builder.Services.AddScoped<ExtractionPlugin>()`
|
||||
|
||||
**Expected friction on target:**
|
||||
- **Scrutor auto-registration**: If CRC's Scrutor scan includes the `Plugins` namespace, 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 (from `System.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.InvalidOperationException` when 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:**
|
||||
1. Copy the `examples/extraction/` folder to the CRC.Server project root
|
||||
2. Create `CRC.Server/Services/FewShotService.cs`
|
||||
3. Register as Singleton in DI
|
||||
4. Ensure the examples path is configurable via `NlxvaPricer:FewShotPath`
|
||||
5. Make sure examples are included in publish output (add to `.csproj` if needed):
|
||||
```xml
|
||||
<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.PrefixMessageCount` should 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: Log `builder.Environment.ContentRootPath` at 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
|
||||
|
||||
**Prerequisites:** T1 (NuGet package installed).
|
||||
|
||||
**Context:** This registers the SK Kernel and OpenAI chat completion connector in DI. The connector works with any OpenAI-compatible API, so we point it at CLIProxyAPI (a local proxy that routes to Claude/GPT).
|
||||
|
||||
**Step-by-step:**
|
||||
1. Add `using Microsoft.SemanticKernel;` to the startup file
|
||||
2. Read config values from `NlxvaPricer:*` section
|
||||
3. Register: `AddOpenAIChatCompletion()` then `AddKernel()`
|
||||
4. The base URL **MUST** include `/v1` — this is the most common misconfiguration
|
||||
|
||||
**Expected friction on target:**
|
||||
- **CLIProxyAPI availability**: The proxy must be running on the target machine at the configured URL. If CRC's server runs on a different machine than the developer's laptop (where CLIProxyAPI runs), you'll need network routing or to deploy CLIProxyAPI alongside CRC.
|
||||
- **API key**: CLIProxyAPI may not check the key, but the SK OpenAI connector requires a non-empty string. Use `"not-needed"` as a placeholder.
|
||||
|
||||
**Verify it works:**
|
||||
- `dotnet build` succeeds (SK NuGet resolved correctly)
|
||||
- At runtime: inject `Kernel` into a test controller and verify it resolves
|
||||
- Quick smoke test: call `kernel.GetRequiredService<IChatCompletionService>()` — should not throw
|
||||
|
||||
**If it breaks — diagnostic checklist:**
|
||||
- Symptom: 404 on LLM requests
|
||||
Cause: Base URL missing `/v1`
|
||||
Fix: Change `http://localhost:8317` to `http://localhost:8317/v1`
|
||||
- Symptom: `HttpRequestException: Connection refused`
|
||||
Cause: CLIProxyAPI not running
|
||||
Fix: Start CLIProxyAPI on the target machine, verify with `curl http://localhost:8317/v1/models`
|
||||
- Symptom: `InvalidOperationException: No service for type IChatCompletionService`
|
||||
Cause: `AddOpenAIChatCompletion()` not called before `AddKernel()`
|
||||
Fix: Ensure registration order: OpenAIChatCompletion first, then Kernel
|
||||
|
||||
---
|
||||
|
||||
### 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:**
|
||||
1. Create `CRC.Server/Controllers/NlxvaPricerController.cs`
|
||||
2. Route: `[Route("api/nlxva-pricer")]`
|
||||
3. Inject `Kernel` via constructor
|
||||
4. In each action: resolve ExtractionPlugin from `HttpContext.RequestServices`, import into Kernel
|
||||
5. 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 allows `application/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-stream`
|
||||
Fix: Update CORS policy in CRC.Server to allow `AllowAnyHeader()` or specifically `text/event-stream`
|
||||
- Symptom: Tools called but response appears to hang
|
||||
Cause: `Response.Body.FlushAsync()` not called after each SSE write
|
||||
Fix: Ensure both `WriteAsync` and `FlushAsync` are called after each `data:` line
|
||||
- Symptom: Empty response (no tokens)
|
||||
Cause: SK's `GetStreamingChatMessageContentsAsync` returns tool-call chunks (not text) that are being skipped, but no text chunks follow
|
||||
Fix: Check that `FunctionChoiceBehavior.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:**
|
||||
1. Check if CRC already has a markdown renderer (grep for `Markdig` or `MarkupString` in CRC.Client)
|
||||
2. If not, create `CRC.Client/Services/MarkdownService.cs`
|
||||
3. Register as Singleton
|
||||
4. 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.Compiled` for 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:**
|
||||
1. Create `CRC.Client/Services/NlxvaPricerApiClient.cs`
|
||||
2. Register with `AddHttpClient<NlxvaPricerApiClient>()` 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 `<script src="js/file-drop.js"></script>` 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: `<PageTitle>NL XVA Pricer</PageTitle>`
|
||||
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:LlmBaseUrl` in CRC.Server `appsettings.json` — default `http://localhost:8317/v1`
|
||||
- [ ] `NlxvaPricer:LlmModel` in CRC.Server `appsettings.json` — default `claude-sonnet-4-6`
|
||||
- [ ] `NlxvaPricer:LlmApiKey` in CRC.Server `appsettings.json` — default `not-needed`
|
||||
- [ ] `NlxvaPricer:FewShotPath` in CRC.Server `appsettings.json` — default `examples/extraction`
|
||||
- [ ] `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. Start CLIProxyAPI on target machine
|
||||
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 `/v1/chat/completions` | Base URL missing `/v1` suffix | Set `NlxvaPricer:LlmBaseUrl` to `http://localhost:8317/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 | `HttpRequestException: Connection refused` | CLIProxyAPI not running | Start proxy; verify with `curl http://localhost:8317/v1/models` |
|
||||
| 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`, `OpenAI` SDK. 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
|
||||
|
||||
### 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.cs`
|
||||
- `CRC.Server/Plugins/ExtractionPlugin.cs`
|
||||
- `CRC.Server/Services/FewShotService.cs`
|
||||
- `CRC.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.css`
|
||||
- `CRC.Client/Services/NlxvaPricerApiClient.cs`
|
||||
- `CRC.Client/Services/MarkdownService.cs`
|
||||
- `CRC.Client/wwwroot/js/file-drop.js`
|
||||
- `CRC.Shared/Models/Nlxva*.cs` files
|
||||
- `examples/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`: remove `NlxvaPricer` and `ExternalApis` sections
|
||||
- CRC.Client `Program.cs`: remove NlxvaPricerApiClient and MarkdownService registrations
|
||||
|
||||
**NuGet packages to remove:**
|
||||
- `Microsoft.SemanticKernel` from CRC.Server
|
||||
- `Markdig` from CRC.Client (if not used by other features)
|
||||
|
||||
**Config keys to remove:**
|
||||
- `NlxvaPricer:*` section from `appsettings.json`
|
||||
- `ExternalApis:*` section (if only used by this feature)
|
||||
512
openspec/exports/nlxva-pricer-spec.md
Normal file
512
openspec/exports/nlxva-pricer-spec.md
Normal file
@@ -0,0 +1,512 @@
|
||||
# Feature: Natural Language XVA Pricer
|
||||
## Target: CRC (Blazor WASM Hosted / ASP.NET Core / MudBlazor / .NET 8.0)
|
||||
## Source: ChatAgent — cumulative export of all 12 changes
|
||||
## Includes: chat-ui, chat-streaming, semantic-kernel, multi-turn, rich-text, sidebar-nav, prompt-settings, extraction-schema, extraction-tools, few-shot-prompting, extraction-endpoint, email-upload
|
||||
## Skipped: migrate-claude-md-to-openspec (documentation only), add-test-coverage (adapt to CRC test conventions separately)
|
||||
|
||||
---
|
||||
|
||||
## Integration Rule
|
||||
|
||||
This feature is a GUEST in CRC. Existing code, patterns, and conventions take absolute precedence.
|
||||
|
||||
- **DO NOT** modify existing files, components, layouts, services, routing, or DI registrations in CRC
|
||||
- **DO NOT** replace existing patterns (e.g., if CRC uses a different HttpClient pattern, use theirs)
|
||||
- **DO** add new files, new nav links, new routes, new DI registrations
|
||||
- **DO** conform to CRC naming conventions: `E`-prefix enums, `I`-prefix interfaces, `*Dto`/`*Request`/`*Response` DTOs, PascalCase constants, `{Subject}Test` test classes
|
||||
- **DO** use CRC.Shared for DTOs (not a new shared project)
|
||||
- If a task conflicts with existing CRC code, **STOP and ask the user**
|
||||
- If CRC already has an equivalent service (HttpClient wrapper, markdown renderer), **use the existing one**
|
||||
|
||||
### Adapt-to-target notes
|
||||
- CRC uses `CRC.Server` (not a standalone API project) — add controller and services there
|
||||
- CRC uses `CRC.Client` — add pages, layout changes, client services there
|
||||
- CRC uses `CRC.Shared` — add DTOs there
|
||||
- CRC uses Scrutor for DI assembly scanning — register new services compatibly
|
||||
- CRC uses Fluxor for client state — this feature uses local component state (no Fluxor needed), which is fine for an isolated page
|
||||
- CRC uses Serilog — use `ILogger<T>` via DI (Serilog handles the sink)
|
||||
- CRC uses Azure AD auth in prod, DevAuth in dev — add `[Authorize]` if CRC controllers require it
|
||||
- CRC uses `gv_web_config.csv` as primary config — put LLM config in `appsettings.json` (secondary config) where CRC already stores Serilog/DevAuth settings
|
||||
- CRC AppBar is regular height (64px), not Dense (48px) — adjust CSS calc accordingly
|
||||
|
||||
## Target Layout
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| CRC AppBar (64px, blue, Elevation 1) |
|
||||
| [=] CRC 0.0.0 APR-CRC-PROD-LDN-DEV |
|
||||
+------+-----------------------------------------------------------+
|
||||
| Drawer| MudMainContent |
|
||||
| Home | |
|
||||
| Pricer| (routed page content) |
|
||||
| Mkt | |
|
||||
| XVA | |
|
||||
| Sales | |
|
||||
|>NLPric| <-- NEW: "NL XVA Pricer" nav item, route /nlxva-pricer |
|
||||
| | |
|
||||
+------+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
- Feature name: **NL XVA Pricer** (short for Natural Language XVA Pricer)
|
||||
- Route: `/nlxva-pricer`
|
||||
- Navigation: new MudNavLink in the existing NavMenu component
|
||||
- Icon: `Icons.Material.Filled.SmartToy`
|
||||
- AppBar height: 64px (CRC uses regular, NOT Dense)
|
||||
- CSS viewport calc: `calc(100vh - 64px)` (NOT 48px)
|
||||
|
||||
## Packages
|
||||
|
||||
Add to `CRC.Server`:
|
||||
- `Microsoft.SemanticKernel` (latest stable, >=1.x)
|
||||
- `Markdig` 1.1.1 (if CRC.Client doesn't already have it — check first)
|
||||
|
||||
No new packages for CRC.Client or CRC.Shared (MudBlazor already present).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CRC.Client (WASM)
|
||||
|
|
||||
| HTTP REST (SSE streaming)
|
||||
|
|
||||
CRC.Server (ASP.NET Core)
|
||||
├── NlxvaPricerController
|
||||
│ ├── POST /api/nlxva-pricer/chat (general chat)
|
||||
│ └── POST /api/nlxva-pricer/extract (email extraction)
|
||||
│ Uses: Semantic Kernel → CLIProxyAPI (OpenAI-compatible proxy)
|
||||
│ Uses: ExtractionPlugin (tool calling)
|
||||
│ Uses: FewShotService (example loading)
|
||||
├── Services/
|
||||
│ ├── FewShotService (singleton, loads examples at startup)
|
||||
│ ├── CounterpartyApiClient (typed HttpClient)
|
||||
│ ├── TradeApiClient (typed HttpClient)
|
||||
│ └── CurrencyApiClient (typed HttpClient)
|
||||
├── Plugins/
|
||||
│ └── ExtractionPlugin ([KernelFunction] tools)
|
||||
├── CRC.Shared (DTOs)
|
||||
└── CRC.Component (if reusable Blazor components needed)
|
||||
```
|
||||
|
||||
Two endpoints, same SSE streaming contract. General chat supports system prompt + model settings.
|
||||
Extraction uses few-shot prefix (not user system prompt) and extraction-specific tools.
|
||||
|
||||
## Components
|
||||
|
||||
### Page: `NlxvaPricer.razor` → `CRC.Client/Pages/NlxvaPricer.razor`
|
||||
- Route: `@page "/nlxva-pricer"`
|
||||
- MudTabs with 3 panels: Chat, System Prompt, Model Settings (KeepPanelsAlive=true)
|
||||
- Chat panel: message list (scrollable), input area (text field + send + upload button), drag-drop zone
|
||||
- Extraction mode: tracked by `_isExtractionMode` bool; routes subsequent messages to extract endpoint
|
||||
- Streaming: consumes `IAsyncEnumerable<string>`, appends token-by-token to assistant message
|
||||
- Markdown rendering: assistant messages rendered via MarkdownService + MarkupString
|
||||
- HTML render cache: `Dictionary<ChatMessage, string>` avoids re-running Markdig on completed messages
|
||||
- JS interop: auto-scroll, drag-and-drop file handling via `file-drop.js`
|
||||
|
||||
### Client service: `NlxvaPricerApiClient` → `CRC.Client/Services/NlxvaPricerApiClient.cs`
|
||||
- Typed HttpClient wrapper
|
||||
- `SendChatStreamingAsync(NlxvaChatRequest)` → POST /api/nlxva-pricer/chat, returns `IAsyncEnumerable<string>`
|
||||
- `SendExtractionStreamingAsync(NlxvaExtractionRequest)` → POST /api/nlxva-pricer/extract, returns `IAsyncEnumerable<string>`
|
||||
- SSE parsing: read line-by-line, extract `data: {"text":"..."}` events, yield text deltas, stop at `[DONE]`
|
||||
|
||||
### Client service: `MarkdownService` → `CRC.Client/Services/MarkdownService.cs`
|
||||
- Markdig pipeline with `UseAdvancedExtensions()`
|
||||
- HTML sanitization via tag/attribute allowlist (p, h1-h6, strong, em, code, pre, ul, ol, li, a[href], table/thead/tbody/tr/th/td, br, blockquote)
|
||||
- Strips `<script>`, `<style>` blocks entirely, strips event handler attributes
|
||||
- Singleton registration
|
||||
|
||||
### Controller: `NlxvaPricerController` → `CRC.Server/Controllers/NlxvaPricerController.cs`
|
||||
- `[Route("api/nlxva-pricer")]`
|
||||
- `POST /` (chat): builds SK ChatHistory from messages + optional system prompt, streams SSE
|
||||
- `POST /extract`: builds ChatHistory from FewShotService prefix + email, streams SSE
|
||||
- Both endpoints: import ExtractionPlugin, enable `FunctionChoiceBehavior.Auto()`
|
||||
- SSE format: `data: {"text":"..."}\n\n` per token, `data: [DONE]\n\n` at end, `data: {"error":"..."}\n\n` on failure
|
||||
|
||||
### Plugin: `ExtractionPlugin` → `CRC.Server/Plugins/ExtractionPlugin.cs`
|
||||
- 4 `[KernelFunction]` methods:
|
||||
- `lookup_counterparty(string name)` → calls CounterpartyApiClient, returns JSON ValidationResult
|
||||
- `validate_trade(long tradeId)` → calls TradeApiClient
|
||||
- `validate_currency(string currencyCode)` → calls CurrencyApiClient
|
||||
- `validate_schema(string extractionResultJson)` → local JSON validation against TradeItem schema
|
||||
- All return serialized `ValidationResult` JSON (so LLM can reason about it)
|
||||
- HTTP errors caught and returned as structured messages (not thrown)
|
||||
|
||||
### Service: `FewShotService` → `CRC.Server/Services/FewShotService.cs`
|
||||
- Loads instruction template + few-shot examples from disk at startup
|
||||
- Caches a `ChatHistory` prefix (system message + alternating user/assistant example turns)
|
||||
- `CloneWithEmail(string emailHtml)` → clones prefix + appends email as final user message
|
||||
- `CloneWithEmailAndMessages(string emailHtml, List<NlxvaChatMessage> messages)` → for follow-ups
|
||||
- Singleton lifetime
|
||||
|
||||
### API Clients: `CounterpartyApiClient`, `TradeApiClient`, `CurrencyApiClient`
|
||||
- Each: typed HttpClient with single async method wrapping an external API call
|
||||
- Registered via `AddHttpClient<T>()` with base URL from appsettings.json
|
||||
- CounterpartyApiClient.LookupAsync(name) → `GET lookup?name={name}` → `List<CandidateMatch>`
|
||||
- TradeApiClient.ValidateAsync(tradeId) → `GET validate/{tradeId}` → `TradeValidationResponse`
|
||||
- CurrencyApiClient.ValidateAsync(code) → `GET validate/{code}` → `CurrencyValidationResponse`
|
||||
|
||||
### JS: `file-drop.js` → `CRC.Client/wwwroot/js/file-drop.js`
|
||||
- Registers dragover/dragenter/dragleave/drop handlers on a CSS-selector target
|
||||
- Reads dropped file as text via FileReader
|
||||
- Calls back to .NET via `DotNetObjectReference.invokeMethodAsync`
|
||||
|
||||
## Contracts
|
||||
|
||||
### DTOs (all in CRC.Shared namespace, adapt naming to CRC conventions)
|
||||
|
||||
```csharp
|
||||
// NlxvaChatMessage.cs
|
||||
public class NlxvaChatMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty; // "user" | "assistant"
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
// NlxvaChatRequest.cs — POST /api/nlxva-pricer/chat
|
||||
public class NlxvaChatRequest
|
||||
{
|
||||
public List<NlxvaChatMessage> Messages { get; set; } = new();
|
||||
public string? SystemPrompt { get; set; }
|
||||
public NlxvaModelSettings? Settings { get; set; }
|
||||
}
|
||||
|
||||
// NlxvaModelSettings.cs
|
||||
public class NlxvaModelSettings
|
||||
{
|
||||
public double? Temperature { get; set; } // 0.0–2.0
|
||||
public double? TopP { get; set; } // 0.0–1.0
|
||||
public int? MaxTokens { get; set; } // 1–4096
|
||||
}
|
||||
|
||||
// NlxvaExtractionRequest.cs — POST /api/nlxva-pricer/extract
|
||||
public class NlxvaExtractionRequest
|
||||
{
|
||||
public string EmailHtml { get; set; } = string.Empty;
|
||||
public List<NlxvaChatMessage> Messages { get; set; } = new();
|
||||
}
|
||||
|
||||
// TradeItem.cs — snake_case JSON for downstream systems
|
||||
public class TradeItem
|
||||
{
|
||||
[JsonPropertyName("valuedate")] public string? Valuedate { get; set; }
|
||||
[JsonPropertyName("counterparty")] public string? Counterparty { get; set; }
|
||||
[JsonPropertyName("legal_entity")] public string? LegalEntity { get; set; }
|
||||
[JsonPropertyName("trade_id")] public long TradeId { get; set; }
|
||||
[JsonPropertyName("display_ccy")] public string? DisplayCcy { get; set; }
|
||||
[JsonPropertyName("pv")] public double Pv { get; set; }
|
||||
[JsonPropertyName("breakclause")] public string? Breakclause { get; set; }
|
||||
}
|
||||
|
||||
// NlxvaExtractionResult.cs
|
||||
public class NlxvaExtractionResult
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<TradeItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
// NlxvaValidationResult.cs
|
||||
public class NlxvaValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public List<NlxvaCandidateMatch>? Candidates { get; set; }
|
||||
}
|
||||
|
||||
public class NlxvaCandidateMatch
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string LegalEntity { get; set; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
### SSE Wire Format
|
||||
|
||||
```
|
||||
data: {"text":"token here"}\n\n ← per token
|
||||
data: [DONE]\n\n ← stream complete
|
||||
data: {"error":"message"}\n\n ← on failure (followed by [DONE])
|
||||
```
|
||||
|
||||
### Config keys (appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"NlxvaPricer": {
|
||||
"LlmBaseUrl": "http://localhost:8317/v1",
|
||||
"LlmModel": "claude-sonnet-4-6",
|
||||
"LlmApiKey": "not-needed",
|
||||
"FewShotPath": "examples/extraction"
|
||||
},
|
||||
"ExternalApis": {
|
||||
"CounterpartyBaseUrl": "http://localhost:5000/api/counterparty",
|
||||
"TradeBaseUrl": "http://localhost:5000/api/trade",
|
||||
"CurrencyBaseUrl": "http://localhost:5000/api/currency"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
### 1. SSE streaming in Blazor WASM — DO NOT use `reader.EndOfStream`
|
||||
|
||||
**Why:** `EndOfStream` performs a synchronous peek read. Blazor WASM's async streaming pipeline
|
||||
does not support synchronous reads — it will hang or throw.
|
||||
|
||||
**Copy this pattern:**
|
||||
```csharp
|
||||
httpRequest.SetBrowserResponseStreamingEnabled(true);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null) // ← NOT EndOfStream
|
||||
{
|
||||
if (!line.StartsWith("data: ")) continue;
|
||||
var data = line.Substring(6);
|
||||
if (data == "[DONE]") yield break;
|
||||
// parse {"text":"..."} and yield
|
||||
}
|
||||
```
|
||||
|
||||
`SetBrowserResponseStreamingEnabled(true)` is a Blazor WASM extension that tells the browser Fetch API
|
||||
to expose the response as a ReadableStream. Without it, the browser buffers the entire response.
|
||||
|
||||
### 2. Semantic Kernel base URL must include `/v1`
|
||||
|
||||
**Why:** The OpenAI SDK appends `chat/completions` directly to the base URL.
|
||||
Without `/v1`, requests hit `/chat/completions` instead of `/v1/chat/completions` → 404.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddOpenAIChatCompletion(
|
||||
modelId: model,
|
||||
endpoint: new Uri("http://localhost:8317/v1"), // ← MUST include /v1
|
||||
apiKey: "not-needed");
|
||||
```
|
||||
|
||||
### 3. Layout height depends on AppBar height
|
||||
|
||||
**Why:** CRC uses a regular AppBar (64px), not Dense (48px). Magic CSS values must match.
|
||||
|
||||
```css
|
||||
::deep .tab-container {
|
||||
height: calc(100vh - 64px); /* 64px = CRC regular AppBar height */
|
||||
}
|
||||
```
|
||||
|
||||
If CRC uses `100dvh` elsewhere, prefer that over `100vh` for mobile viewport correctness.
|
||||
|
||||
### 4. Markdown render caching during streaming
|
||||
|
||||
**Why:** Without caching, every `StateHasChanged()` during streaming re-runs Markdig on ALL messages,
|
||||
causing visible lag as conversation grows. Only the streaming message should re-render.
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<NlxvaChatMessage, string> _renderedHtmlCache = new();
|
||||
|
||||
private string GetRenderedHtml(NlxvaChatMessage message)
|
||||
{
|
||||
if (_renderedHtmlCache.TryGetValue(message, out var cached))
|
||||
return cached;
|
||||
return Markdown.ConvertToHtml(message.Content);
|
||||
}
|
||||
|
||||
// In finally block after streaming completes:
|
||||
_renderedHtmlCache[assistantMessage] = Markdown.ConvertToHtml(assistantMessage.Content);
|
||||
```
|
||||
|
||||
### 5. ExtractionPlugin tool results must be serialized JSON strings
|
||||
|
||||
**Why:** SK passes the return value as a string to the LLM. The LLM needs structured JSON
|
||||
to reason about validation results, error messages, and candidate lists.
|
||||
|
||||
```csharp
|
||||
[KernelFunction("lookup_counterparty")]
|
||||
[Description("Looks up counterparty candidates by name...")]
|
||||
public async Task<string> LookupCounterparty(string name)
|
||||
{
|
||||
var result = new NlxvaValidationResult();
|
||||
// ... populate result ...
|
||||
return JsonSerializer.Serialize(result); // ← return JSON string, not object
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Per-request plugin import (not global)
|
||||
|
||||
**Why:** Plugins that depend on scoped services (typed HttpClients) must be imported per-request,
|
||||
not registered globally on the Kernel at startup.
|
||||
|
||||
```csharp
|
||||
var extractionPlugin = HttpContext.RequestServices.GetRequiredService<ExtractionPlugin>();
|
||||
_kernel.ImportPluginFromObject(extractionPlugin, "Extraction");
|
||||
```
|
||||
|
||||
### 7. C# yield cannot appear inside try-catch
|
||||
|
||||
**Why:** Language restriction. SSE parsing needs to parse JSON (can throw) and yield (can't be in try).
|
||||
Solution: parse into local variables first, yield outside try block.
|
||||
|
||||
```csharp
|
||||
string? parsedText = null;
|
||||
string? parsedError = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("error", out var err))
|
||||
parsedError = err.GetString();
|
||||
else if (root.TryGetProperty("text", out var txt))
|
||||
parsedText = txt.GetString();
|
||||
}
|
||||
catch (JsonException) { /* skip malformed */ }
|
||||
|
||||
if (parsedError != null)
|
||||
throw new HttpRequestException($"API error: {parsedError}");
|
||||
if (!string.IsNullOrEmpty(parsedText))
|
||||
yield return parsedText;
|
||||
```
|
||||
|
||||
## Wiring
|
||||
|
||||
### CRC.Server DI registration order (add to existing Program.cs / Startup.cs)
|
||||
|
||||
```csharp
|
||||
// 1. Semantic Kernel — OpenAI-compatible connector
|
||||
var llmBaseUrl = builder.Configuration["NlxvaPricer:LlmBaseUrl"] ?? "http://localhost:8317/v1";
|
||||
var llmModel = builder.Configuration["NlxvaPricer:LlmModel"] ?? "claude-sonnet-4-6";
|
||||
builder.Services.AddOpenAIChatCompletion(
|
||||
modelId: llmModel,
|
||||
endpoint: new Uri(llmBaseUrl),
|
||||
apiKey: builder.Configuration["NlxvaPricer:LlmApiKey"] ?? "not-needed");
|
||||
builder.Services.AddKernel();
|
||||
|
||||
// 2. External API typed HttpClients
|
||||
builder.Services.AddHttpClient<CounterpartyApiClient>(c =>
|
||||
c.BaseAddress = new Uri(builder.Configuration["ExternalApis:CounterpartyBaseUrl"]
|
||||
?? "http://localhost:5000/api/counterparty"));
|
||||
builder.Services.AddHttpClient<TradeApiClient>(c =>
|
||||
c.BaseAddress = new Uri(builder.Configuration["ExternalApis:TradeBaseUrl"]
|
||||
?? "http://localhost:5000/api/trade"));
|
||||
builder.Services.AddHttpClient<CurrencyApiClient>(c =>
|
||||
c.BaseAddress = new Uri(builder.Configuration["ExternalApis:CurrencyBaseUrl"]
|
||||
?? "http://localhost:5000/api/currency"));
|
||||
|
||||
// 3. FewShotService (singleton — loads examples once at startup)
|
||||
var fewShotPath = builder.Configuration["NlxvaPricer:FewShotPath"] ?? "examples/extraction";
|
||||
var fewShotAbsPath = Path.IsPathRooted(fewShotPath)
|
||||
? fewShotPath
|
||||
: Path.Combine(builder.Environment.ContentRootPath, fewShotPath);
|
||||
builder.Services.AddSingleton(new FewShotService(fewShotAbsPath));
|
||||
|
||||
// 4. ExtractionPlugin (scoped — depends on scoped HttpClients)
|
||||
builder.Services.AddScoped<ExtractionPlugin>();
|
||||
```
|
||||
|
||||
### CRC.Client DI registration (add to existing Program.cs)
|
||||
|
||||
```csharp
|
||||
// Typed HttpClient for the NL XVA Pricer API
|
||||
builder.Services.AddHttpClient<NlxvaPricerApiClient>(c =>
|
||||
c.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]
|
||||
?? "https://localhost:7100/"));
|
||||
|
||||
// Markdown rendering (singleton — thread-safe, reusable)
|
||||
builder.Services.AddSingleton<MarkdownService>();
|
||||
```
|
||||
|
||||
### CRC.Client NavMenu (add new MudNavLink)
|
||||
|
||||
```razor
|
||||
<MudNavLink Href="/nlxva-pricer"
|
||||
Icon="@Icons.Material.Filled.SmartToy"
|
||||
Match="NavLinkMatch.All">
|
||||
NL XVA Pricer
|
||||
</MudNavLink>
|
||||
```
|
||||
|
||||
### CRC.Client index.html (add JS reference)
|
||||
|
||||
```html
|
||||
<script src="js/file-drop.js"></script>
|
||||
```
|
||||
|
||||
### CRC.Server CORS (if not already allowing the client origin)
|
||||
|
||||
Ensure the CORS policy allows the CRC.Client origin for the new endpoints.
|
||||
|
||||
### Examples folder
|
||||
|
||||
Copy the `examples/extraction/` folder to the CRC.Server project root:
|
||||
```
|
||||
examples/extraction/
|
||||
├── instruction-template.txt
|
||||
└── few-shot/
|
||||
├── 01/
|
||||
│ ├── input.html
|
||||
│ └── output.json
|
||||
├── 02/
|
||||
│ ├── input.html
|
||||
│ └── output.json
|
||||
└── 03/
|
||||
├── input.html
|
||||
└── output.json
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Extraction mode routing**: When an email is uploaded, `_isExtractionMode = true`. All subsequent text messages route to `/extract` (not `/chat`) until "New Chat" resets
|
||||
- **Follow-up disambiguation**: The extraction endpoint receives full conversation history (email + all prior exchanges) so the agent has context for disambiguation
|
||||
- **Upload message**: File upload adds a user message `[Uploaded: filename.html]` to the chat before streaming the extraction response
|
||||
- **File validation**: Only `.html` files accepted (both drag-drop and file picker). Others show MudAlert warning
|
||||
- **Streaming guard**: Input field, send button, upload button, and drop zone all disabled during streaming
|
||||
- **Multi-turn context**: General chat sends full conversation history with every request
|
||||
- **System prompt**: Only used for general chat, NOT for extraction (extraction uses fixed instruction template)
|
||||
- **Model settings**: Only used for general chat, NOT for extraction
|
||||
- **Settings persistence**: In-memory only (lost on page refresh) — acceptable for a debugging/iteration tool
|
||||
- **DotNetObjectReference disposal**: Chat page implements IDisposable to dispose the JS interop reference
|
||||
|
||||
## Few-Shot Instruction Template
|
||||
|
||||
The instruction template defines the extraction task. Content:
|
||||
|
||||
```
|
||||
You are a trade data extraction agent. Your task is to extract structured trade data
|
||||
from sales emails (typically CVA pricing requests) and return the result as JSON.
|
||||
|
||||
## Output Schema
|
||||
Return a JSON object with an "items" array. Each item has:
|
||||
- valuedate (string): dd/MM/yyyy format
|
||||
- counterparty (string): full legal name from email
|
||||
- trade_id (integer): Murex trade ID
|
||||
- display_ccy (string): ISO currency code (£→GBP, $→USD, €→EUR)
|
||||
- pv (number): plain number, no formatting
|
||||
- breakclause (string): "Y" or "N" (default "N")
|
||||
|
||||
legal_entity is NOT included — populated later via lookup tool.
|
||||
|
||||
## Mapping Rules
|
||||
1. FLATTEN: Each leg with unique Murex ID → separate item
|
||||
2. DATE: Parse from context (e.g., "OB 27/11/2025" → "27/11/2025")
|
||||
3. COUNTERPARTY: Full legal name exactly as written
|
||||
4. CURRENCY: From PV column header (£→GBP, $→USD, €→EUR)
|
||||
5. PV: Strip commas/symbols, plain number
|
||||
6. BREAKCLAUSE: Default "N", only "Y" if explicitly mentioned
|
||||
|
||||
## After Extraction
|
||||
Use tools: lookup_counterparty, validate_trade, validate_currency, validate_schema.
|
||||
If multiple candidates, present numbered list and ask user to select.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compression Stats
|
||||
|
||||
- Source code: ~3,200 lines across 25+ files
|
||||
- This spec: ~350 lines
|
||||
- Compression ratio: ~9:1
|
||||
- Estimated typing: ~12,000 characters (vs ~110,000 for full code)
|
||||
Reference in New Issue
Block a user