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>
672 lines
44 KiB
Markdown
672 lines
44 KiB
Markdown
# 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)
|