Compare commits

..

2 Commits

Author SHA1 Message Date
local
d46b179221 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>
2026-04-07 00:45:47 +01:00
local
5b027eb0db feat: add extraction schema, sidebar nav, few-shot prompting, and prompt settings
Overhaul extraction pipeline with new TradeItem model, conversation flow,
and dedicated extraction endpoint. Add sidebar navigation with NavMenu
component and landing page. Introduce few-shot prompting service and
tests. Add prompt settings and email upload specs. Update OpenSpec
tooling with improved export-spec and extract-feature commands. Archive
completed changes and export full specs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:39:23 +01:00
87 changed files with 5854 additions and 296 deletions

View File

@@ -54,7 +54,33 @@ Instead of retyping code, you retype a compact spec. The AI on the sandbox gener
This shapes what the spec assumes vs what it must specify.
5. **Generate the portable spec**
5. **Capture the target GUI layout**
If the feature has a UI component, the spec MUST include the target's layout context.
Without it, the AI will generate components that conflict with the existing shell.
Ask the user:
> "Does the target app have an existing GUI shell? If so, share:
> 1. A **screenshot** (drag/drop or file path) — most information-dense
> 2. Or describe: AppBar (height? Dense?), Sidebar/Drawer (width? toggled?),
> MainContent area, any fixed elements"
If a screenshot is provided, extract:
- AppBar type and height (Dense=48px, Regular=64px, custom)
- Sidebar/Drawer presence, width, toggle behavior
- Main content area constraints
- Existing navigation items
Then ask:
> "How should this feature integrate into the target?
> 1. Feature name in the target (e.g., 'Sales Assistant' not 'Chat')
> 2. Route path (e.g., `/sales-assistant`)
> 3. Navigation: new sidebar item? AppBar button? Floating panel?"
Generate an **ASCII layout diagram** showing exactly where the feature renders,
and include it in both the spec and the OpenSpec design.md.
6. **Generate the portable spec**
Create a single markdown document that is:
- **Compact**: Target ~30-50 lines for a medium feature
@@ -68,6 +94,21 @@ Instead of retyping code, you retype a compact spec. The AI on the sandbox gener
# Feature: <Name>
## Target: <project name> (<stack>)
## Assumes
<what must already exist on the target>
## Integration Rule
This feature is additive only. DO NOT modify existing files, components,
services, or patterns in the target. If the target already has an equivalent
service, use it instead of creating a new one. If a task conflicts with
existing target code, skip it and note the conflict. Existing applicationX
code takes precedence over this spec in all cases.
## Target Layout
<ASCII diagram showing the target app shell and where this feature renders>
<AppBar height, Sidebar width, MainContent constraints>
<Feature name, route, navigation integration point>
## Packages
<list with versions>
@@ -83,6 +124,11 @@ Instead of retyping code, you retype a compact spec. The AI on the sandbox gener
## Contracts
<API shapes, model definitions — things that MUST be exact>
## Critical Patterns
<Exact code snippets for traps the AI will otherwise get wrong>
<Show the POSITIVE pattern to copy, not just "don't use X">
<Include WHY — the mechanism behind the trap>
## Wiring
<DI registration, middleware order, config keys — dependency order>
@@ -97,20 +143,26 @@ Instead of retyping code, you retype a compact spec. The AI on the sandbox gener
- Only specify non-obvious behavior
- Omit anything the AI would do by default
6. **Estimate typing effort**
7. **Estimate typing effort**
Count characters in the spec. Compare to the code recipe equivalent.
Show the compression ratio.
7. **Optionally generate an OpenSpec-compatible version**
8. **Optionally generate an OpenSpec-compatible version**
If the target has OpenSpec, also generate:
- A **config.yaml** template — adapted context for the target project, with placeholders
- A `proposal.md` (minimal — 5-10 lines)
- A `design.md` — compact architectural decisions and rationale (why SSE vs WebSocket,
why typed client vs raw, why per-request plugin import, etc.). Without this the AI
has tasks but no rationale, and will guess wrong on non-obvious decisions.
- A `tasks.md` (implementation steps)
- **Setup instructions** at the top: step-by-step procedure for the target machine
(create config.yaml, save proposal/design/tasks, run `/opsx:apply`, reference the portable spec)
Save as: `openspec/exports/<change-name>-openspec.md`
8. **Write the output**
9. **Write the output**
Save to: `openspec/exports/<change-name>-spec.md`
Display the full content for review.
@@ -128,4 +180,48 @@ Instead of retyping code, you retype a compact spec. The AI on the sandbox gener
- In delta mode, add an "Assumes" section so the target AI knows what must exist
- In the output header, note which changes were included and which were skipped
**Integration rule: adapt, never modify**
The exported feature is a GUEST in the target application. Existing code, patterns,
and conventions in the target take absolute precedence. The spec must include an
explicit "Integration Rule" section that states:
- **DO NOT** modify existing files, components, layouts, services, or routing in the target
- **DO NOT** replace existing patterns with patterns from the source project
(e.g., if the target uses a different HttpClient pattern, use theirs)
- **DO** add new files, new nav links, new routes, new DI registrations
- **DO** conform to the target's existing code style, naming conventions, and project structure
- If a task conflicts with existing target code, **stop and notify the user** — the user decides whether to skip, adapt, or redesign
- If the target already has an equivalent service (e.g., its own markdown renderer,
HttpClient wrapper), **use the existing one** instead of creating a new one
When generating the spec, actively identify potential conflict points and add
explicit "Adapt to target" notes. Common conflicts:
- Program.cs / DI registration style
- HttpClient patterns (typed client vs named client vs raw)
- Layout components (don't restructure MainLayout)
- CSS approach (isolation vs global vs utility classes)
- Error handling patterns
- Navigation structure
**Anti-patterns learned from field use**
These patterns cause the target AI to deviate from the spec even when the spec mentions them:
1. **"Don't use X" warnings get ignored.** AIs skip parenthetical negations buried in
bullet lists because their training data overwhelmingly uses the standard pattern.
Instead: add a **"Critical Patterns"** section with the exact code snippet to copy.
Show the positive pattern, not just the negative warning. E.g., instead of
"don't use EndOfStream", show the exact `while ((line = await ReadLineAsync()) != null)` loop.
2. **Layout values that depend on other components break silently.** Magic numbers like
`calc(100vh - 48px)` assume a specific AppBar height. Instead: document the dependency
explicitly ("48px = MudAppBar Dense height"), suggest using a CSS variable as fallback,
and note mobile viewport gotchas (`100vh` vs `100dvh`).
3. **Platform-specific runtime traps need the WHY.** Saying "don't use EndOfStream" is
not enough — the AI needs to know WHY (synchronous peek on an async-only stream).
When the AI understands the mechanism, it's less likely to reach for an equivalent
pattern that has the same problem.
ARGUMENTS: based on the above

View File

@@ -105,6 +105,7 @@ Print it, take it to the sandbox, type it in.
- Mark domain-specific code clearly so the user knows what to adapt vs copy verbatim
- Keep each step self-contained — the user may take breaks between steps
- If a feature spans more than ~200 lines of stripped code, warn the user and suggest using `/opsx:export-spec` instead
- If the feature has UI components, warn that the code recipe does not capture layout context (AppBar height, sidebar width, container sizing). Suggest `/opsx:export-spec` for UI-heavy features — it includes a Target Layout section with ASCII diagram and integration guidance
- Output must be valid markdown that renders well when printed
- When in cumulative mode, skip superseded code — always use the latest version of each file
- In the output header, show which changes were included and which were skipped

View File

@@ -0,0 +1,233 @@
---
name: "OPSX: Porting Guide"
description: Generate a detailed human-readable implementation guide for porting a feature to a target codebase
category: Workflow
tags: [workflow, portability, experimental]
---
Generate a detailed human-readable porting guide for implementing an exported feature on a target codebase.
This is a **companion** to `/opsx:export-spec`. The export-spec is optimized for AI consumption (compact, precise). This guide is optimized for the **human developer** who needs to understand context, troubleshoot issues, and make judgment calls when the AI agent on the target hits problems.
---
**Input**: The argument after `/opsx:porting-guide` is a change name or feature name that has already been exported via `/opsx:export-spec`. If omitted, prompt for selection.
**Prerequisites**: An export-spec must already exist in `openspec/exports/`. This skill reads the export-spec to stay consistent with it.
**Steps**
1. **Locate the export artifacts**
a. Check `openspec/exports/` for matching files:
- `<name>-spec.md` (portable spec — required)
- `<name>-openspec.md` (OpenSpec bundle — optional)
b. If not found, prompt with **AskUserQuestion tool**:
> "No export-spec found for '<name>'. Available exports: [list]. Which one?"
c. Read the export-spec fully — the porting guide must not contradict it.
2. **Read the source material**
Same sources as export-spec, but read for **context and rationale** rather than contracts:
a. All archived change proposals, designs, and tasks in `openspec/changes/archive/`
b. All main specs in `openspec/specs/`
c. The actual implementation source files
d. Any existing export-spec and OpenSpec bundle
Focus on extracting:
- **Decision rationale**: WHY each design choice was made
- **Rejected alternatives**: what was considered and discarded
- **Friction points**: where the source implementation hit problems
- **Implicit knowledge**: things the source developer knew but didn't document
- **Platform traps**: runtime behaviors that aren't obvious from the code
3. **Gather target context**
If not already captured in the export-spec, use **AskUserQuestion tool** to learn:
> "To write accurate porting notes, I need to understand the target:
> 1. What patterns does the target use for DI registration? (manual, Scrutor, Autofac?)
> 2. What's the deployment model? (Azure, on-prem, Docker?)
> 3. Are there known constraints? (network restrictions, NuGet source limits, proxy requirements?)
> 4. Who will be implementing — developer familiarity with the source stack?"
This shapes the level of explanation and which friction points to highlight.
4. **Generate the porting guide**
Create a comprehensive markdown document structured for a human reader.
Use narrative prose where it aids understanding, tables for quick reference,
and checklists for actionable steps.
### Structure:
```markdown
# Porting Guide: <Feature> → <Target>
## Source: <source project> | Export: <export-spec filename>
## How to Use This Guide
Brief orientation:
- Relationship to the AI-targeted export-spec
- When to read this vs when to let the AI work
- How sections are organized
## Architecture Overview
2-3 paragraphs a human can read in 5 minutes to understand the full feature.
Written as narrative, not bullet points. Cover:
- What the feature does end-to-end (user perspective)
- How data flows through the system (request → processing → response)
- What external dependencies exist and why
- The "one thing you must understand" about this feature
## Design Decisions (Detailed)
For EACH significant design decision:
### <Decision Title>
**What we chose:** <the approach>
**Why:** <rationale — the real reason, not the obvious one>
**What we rejected:**
- <Alternative A> — rejected because <specific reason>
- <Alternative B> — rejected because <specific reason>
**When you'd revisit this:** <conditions under which this decision should change>
**Target adaptation:** <how this maps to the target's patterns, what might need adjusting>
Decisions to always cover:
- Framework/library choices (SK over raw HTTP, SSE over WebSocket, etc.)
- Lifetime/scope decisions (singleton vs scoped vs transient)
- State management approach
- Streaming strategy
- Security decisions (sanitization, auth, CORS)
- File/folder organization
## Source → Target Mapping
Side-by-side mapping table showing how source concepts translate to target:
| Source (ChatAgent) | Target (CRC) | Notes |
|---|---|---|
| `ChatAgent.Api/` | `CRC.Server/` | CRC uses hosted model, not standalone API |
| `ChatAgent.Client/` | `CRC.Client/` | Same WASM pattern |
| `builder.Services.AddScoped<T>()` | Check if Scrutor handles this | CRC may auto-scan |
| ... | ... | ... |
Flag EVERY known divergence, no matter how small. The small ones cause the most debugging time.
## Task-by-Task Implementation Notes
For EACH task in the OpenSpec tasks.md:
### <Task ID>: <Task Title>
**Prerequisites:** What must be completed first and verified working.
**Context:** Why this task exists and what it accomplishes in the bigger picture.
A developer who understands the "why" makes better judgment calls when adapting.
**Step-by-step:**
1. <Step with enough detail that someone unfamiliar with the source can follow>
2. <Step>
3. <Step>
**Expected friction on target:**
- <Specific thing that will likely need adaptation and why>
- <CRC pattern that differs from the source approach>
**Verify it works:**
- <Concrete test: "Navigate to X, click Y, you should see Z">
- <Log check: "Watch for 'SK: Auto-invoking function' in Serilog output">
- <Build check: "dotnet build should produce 0 warnings related to this task">
**If it breaks — diagnostic checklist:**
- Symptom: <what you'll see>
Cause: <most likely reason>
Fix: <specific remediation>
- Symptom: <what you'll see>
Cause: <most likely reason>
Fix: <specific remediation>
## Troubleshooting Reference
Comprehensive symptom → cause → fix table for known porting issues.
Organized by category (network, DI, UI, streaming, auth, config).
| Symptom | Likely Cause | Fix |
|---|---|---|
| 404 on /v1/chat/completions | Base URL missing `/v1` | Add `/v1` to `NlxvaPricer:LlmBaseUrl` |
| CORS 403 on SSE endpoint | CORS policy doesn't cover `text/event-stream` | Add origin to CORS policy |
| Streaming hangs, no tokens | `SetBrowserResponseStreamingEnabled` missing | Add to HttpRequestMessage |
| `EndOfStream` throws | Synchronous peek on async-only WASM stream | Use `ReadLineAsync() != null` loop |
| Markdown not rendering | MarkdownService not registered in DI | Add `AddSingleton<MarkdownService>()` |
| Tools never called by LLM | Plugin not imported on request | Move ImportPluginFromObject into action method |
| ... | ... | ... |
Include at least 10-15 entries covering the most likely failure modes.
## Configuration Checklist
Every config key the feature needs, organized as a checklist:
- [ ] `NlxvaPricer:LlmBaseUrl` — Where: `appsettings.json` (CRC.Server) — Default: `http://localhost:8317/v1` — What happens if missing: Falls back to default, fails if proxy not running
- [ ] `NlxvaPricer:LlmModel` — Where: `appsettings.json` — Default: `claude-sonnet-4-6`
- [ ] ... (every key)
## Dependency & Package Notes
For each new package:
- Package name and version constraint
- Why it's needed (one sentence)
- .NET version compatibility notes
- Known conflicts with packages the target might already have
- NuGet source: public nuget.org or internal feed?
(CRC uses internal GV Artifactory — flag if a package might not be available there)
## Rollback Plan
If the feature needs to be removed:
- Which files were added (safe to delete)
- Which files were modified and what was added (revert specific sections)
- Which NuGet packages were added (remove from csproj)
- Which config keys were added (remove from appsettings.json)
```
5. **Cross-check against export-spec**
Before writing, verify:
- Every contract in the export-spec is explained in the porting guide
- Every critical pattern has a troubleshooting entry
- No contradictions between the two documents
- The porting guide adds CONTEXT, not ALTERNATIVE instructions
6. **Write the output**
Save to: `openspec/exports/<name>-porting-guide.md`
Display a summary of sections generated and key highlights.
**Guardrails**
- **Narrative over telegraphic**: This is for humans. Use complete sentences. Explain the "why."
- **Never contradict the export-spec**: The AI spec is the source of truth for contracts and patterns. The porting guide explains and contextualizes, it doesn't override.
- **Anticipate the debugging session**: For every task, imagine the developer hitting a wall. What would they need to know? Write that down.
- **Be specific about the target**: Generic advice ("check your config") is useless. Specific advice ("CRC uses `gv_web_config.csv` for primary config but `appsettings.json` for secondary — LLM config goes in appsettings") saves hours.
- **Include the embarrassing details**: The things that took 30 minutes to figure out in the source (like the `/v1` URL requirement, or `EndOfStream` hanging) — those are the most valuable entries.
- **Flag NuGet source issues**: If the target uses an internal feed (like CRC's GV Artifactory with nuget.org disabled), flag every package that needs to come from a specific source.
- **No code blocks for code's sake**: The export-spec has the exact code. The porting guide references it ("see Critical Pattern #2 in the export-spec") rather than duplicating it. Only include code when it clarifies a specific porting concern.
- **Version-stamp the guide**: Include the date and source commit hash so the reader knows what state the guide reflects.
**Anti-patterns**
1. **Don't duplicate the export-spec.** The export-spec has exact contracts and code snippets. The porting guide has context, rationale, and troubleshooting. They complement each other.
2. **Don't be abstract.** "There might be DI issues" is useless. "CRC uses Scrutor for assembly scanning. If Scrutor auto-registers ExtractionPlugin before your manual AddScoped<ExtractionPlugin>(), you'll get a duplicate registration. Check with `builder.Services.Where(s => s.ServiceType == typeof(ExtractionPlugin))` in a breakpoint" is useful.
3. **Don't skip the obvious.** The developer may be senior but unfamiliar with Semantic Kernel. Explain SK concepts (Kernel, plugins, FunctionChoiceBehavior) in plain language.
4. **Don't assume the happy path.** Network restrictions, proxy requirements, package feed limitations, and auth quirks are the norm in enterprise environments. Address them.
ARGUMENTS: $ARGUMENTS

View File

@@ -0,0 +1,25 @@
<html>
<body>
<p>Subject: RE: AG Inflation swaps CVA Request</p>
<p>Ovi,</p>
<p>Hope you are well.</p>
<p>Could you kindly share indicative CVA for the below two inflation swaps, assuming the counterparty is Assured Guaranty UK Limited (formerly Assured Guaranty (Europe) Ltd), which is rated AA- by S&amp;P and A1 by Moody's please?</p>
<p>OB 27/11/2025</p>
<p><b>Swap 1 please price standalone</b></p>
<table border="1">
<tr><th></th><th>CSA</th><th>Murex</th><th>PV (£)</th></tr>
<tr><td>Coupon Leg</td><td>BTMU_JPY</td><td>79353083</td><td>4,562,456</td></tr>
<tr><td>APD leg</td><td>BTMU_JPY</td><td>79353084</td><td>76,985,170</td></tr>
</table>
<p>Total PV: 81,547,626</p>
<p><b>Swap 2 please price standalone</b></p>
<table border="1">
<tr><th></th><th>CSA</th><th>Murex</th><th>PV (£)</th></tr>
<tr><td>Coupon Leg</td><td>BTMU_JPY</td><td>79353093</td><td>1,663,261</td></tr>
<tr><td>APD leg</td><td>BTMU_JPY</td><td>79353094</td><td>41,333,773</td></tr>
</table>
<p>Total PV: 42,997,034</p>
<p>Many thanks.</p>
<p>Kind regards,</p>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"items": [
{
"valuedate": "27/11/2025",
"counterparty": "Assured Guaranty UK Limited (formerly Assured Guaranty (Europe) Ltd)",
"trade_id": 79353083,
"display_ccy": "GBP",
"pv": 4562456,
"breakclause": "N"
},
{
"valuedate": "27/11/2025",
"counterparty": "Assured Guaranty UK Limited (formerly Assured Guaranty (Europe) Ltd)",
"trade_id": 79353084,
"display_ccy": "GBP",
"pv": 76985170,
"breakclause": "N"
},
{
"valuedate": "27/11/2025",
"counterparty": "Assured Guaranty UK Limited (formerly Assured Guaranty (Europe) Ltd)",
"trade_id": 79353093,
"display_ccy": "GBP",
"pv": 1663261,
"breakclause": "N"
},
{
"valuedate": "27/11/2025",
"counterparty": "Assured Guaranty UK Limited (formerly Assured Guaranty (Europe) Ltd)",
"trade_id": 79353094,
"display_ccy": "GBP",
"pv": 41333773,
"breakclause": "N"
}
]
}

View File

@@ -0,0 +1,14 @@
<html>
<body>
<p>Subject: CVA quote USD interest rate swap</p>
<p>Hi team,</p>
<p>Please provide CVA for the following single interest rate swap with Deutsche Bank AG, London Branch.</p>
<p>Value date: 15/03/2026</p>
<table border="1">
<tr><th></th><th>CSA</th><th>Murex</th><th>PV ($)</th></tr>
<tr><td>Fixed Leg</td><td>DB_USD</td><td>81200451</td><td>12,750,000</td></tr>
</table>
<p>Thanks,</p>
<p>Sarah</p>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{
"items": [
{
"valuedate": "15/03/2026",
"counterparty": "Deutsche Bank AG, London Branch",
"trade_id": 81200451,
"display_ccy": "USD",
"pv": 12750000,
"breakclause": "N"
}
]
}

View File

@@ -0,0 +1,15 @@
<html>
<body>
<p>Subject: RE: CVA Cross-currency swap with break clause</p>
<p>Dear Ovi,</p>
<p>Could you provide indicative CVA for the below cross-currency swap with Barclays Bank PLC? Note: this trade has a break clause at the 5-year point.</p>
<p>OB 01/06/2025</p>
<table border="1">
<tr><th></th><th>CSA</th><th>Murex</th><th>PV (€)</th></tr>
<tr><td>EUR Leg</td><td>BARC_EUR</td><td>77890112</td><td>8,421,300</td></tr>
<tr><td>GBP Leg</td><td>BARC_EUR</td><td>77890113</td><td>22,105,800</td></tr>
</table>
<p>Thanks,</p>
<p>Mark</p>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"items": [
{
"valuedate": "01/06/2025",
"counterparty": "Barclays Bank PLC",
"trade_id": 77890112,
"display_ccy": "EUR",
"pv": 8421300,
"breakclause": "Y"
},
{
"valuedate": "01/06/2025",
"counterparty": "Barclays Bank PLC",
"trade_id": 77890113,
"display_ccy": "EUR",
"pv": 22105800,
"breakclause": "Y"
}
]
}

View File

@@ -0,0 +1,39 @@
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 represents one trade leg and has these fields:
- valuedate (string): The value/observation date in dd/MM/yyyy format. Look for "OB", "Value date", or similar date references in the email.
- counterparty (string): The full legal name of the counterparty as stated in the email prose.
- trade_id (integer): The Murex trade identifier. Each trade leg has a unique Murex ID.
- display_ccy (string): The ISO currency code derived from the email. Map currency symbols: £ → GBP, $ → USD, € → EUR. If stated as an ISO code, use it directly.
- pv (number): The present value as a plain number. Remove commas and currency symbols. Do not round.
- breakclause (string): "Y" if the email mentions a break clause for the trade, "N" otherwise. Default to "N" if not mentioned.
The legal_entity field is NOT included in your output. It is populated later via a counterparty lookup tool.
## Mapping Rules
1. FLATTEN: Each swap leg with a unique Murex trade ID becomes a separate item. A swap with a Coupon Leg (Murex 123) and an APD leg (Murex 456) produces two items.
2. DATE: Parse the value date from context (e.g., "OB 27/11/2025" means valuedate is "27/11/2025"). Always output in dd/MM/yyyy format.
3. COUNTERPARTY: Use the full legal name exactly as written in the email, including any parenthetical former names.
4. CURRENCY: Derive from the PV column header or context. "PV (£)" means GBP. "PV ($)" means USD. "PV (€)" means EUR.
5. PV: Strip formatting (commas, spaces, currency symbols) and output as a plain number.
6. BREAKCLAUSE: Default to "N". Only set to "Y" if the email explicitly mentions a break clause for the trade.
## Output Format
Return ONLY valid JSON. Do not include markdown code fences or explanatory text. Example:
{"items":[{"valuedate":"27/11/2025","counterparty":"Example Corp","trade_id":12345,"display_ccy":"GBP","pv":1234567,"breakclause":"N"}]}
## After Extraction
After producing the initial extraction, use the available validation tools:
- lookup_counterparty: Look up the counterparty name to find matching legal entities.
- validate_trade: Verify each trade ID exists.
- validate_currency: Confirm each currency code is valid.
- validate_schema: Validate the complete extraction result.
If a tool returns multiple candidates (e.g., counterparty lookup), present them to the user as a numbered list and ask which one to use. If a tool indicates an error, attempt to fix the extraction or ask the user for clarification.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-05

View File

@@ -0,0 +1,42 @@
## Context
The app currently uses a minimal MudLayout with just MudAppBar (Dense) + MudMainContent, and a single page at `/`. To support multiple pages, we need standard MudBlazor navigation: a collapsible MudDrawer with a NavMenu component.
## Goals / Non-Goals
**Goals:**
- Add collapsible MudDrawer with hamburger toggle in the AppBar
- Create a NavMenu component with a "Sales Assistant" link
- Move chat page to `/sales-assistant` route
- Maintain the Blazor tutorial style with inline comments
**Non-Goals:**
- Adding multiple pages beyond the existing chat (just the navigation structure)
- Changing the AppBar from Dense to regular
- Adding a default landing page (redirect `/``/sales-assistant` instead)
## Decisions
### MudDrawer configuration
- **Variant**: `DrawerVariant.Mini` — collapses to icon-width rather than fully hiding, so the user always sees the nav rail
- **Alternative considered**: `DrawerVariant.Responsive` — auto-hides on small screens. Rejected because Mini gives a more consistent desktop experience and the app is desktop-first.
- **ClipMode**: `DrawerClipMode.Always` — drawer sits below the AppBar, not beside it
### NavMenu as separate component
- Extract `NavMenu.razor` into `Layout/` alongside MainLayout rather than inlining nav links
- This is standard Blazor project structure and keeps MainLayout focused on shell layout
- The NavMenu will use `MudNavMenu` with `MudNavLink` items
### Route change: `/` → `/sales-assistant`
- The chat page moves to `/sales-assistant` to match navigation naming
- Add a redirect component at `/` that navigates to `/sales-assistant` on init
- This avoids a blank landing page while keeping the URL structure clean
### AppBar hamburger toggle
- Add `MudIconButton` with `Icons.Material.Filled.Menu` as the first element in the AppBar
- Toggle `_drawerOpen` bool that binds to `MudDrawer.Open`
## Risks / Trade-offs
- **Chat container height**: Currently uses `calc(100vh - 48px)` assuming Dense AppBar (48px). MudDrawer with ClipMode.Always doesn't affect vertical calc, so this should remain correct. Verify after implementation.
- **Breaking bookmarks**: Anyone bookmarking `/` will need to update to `/sales-assistant`. Mitigated by the redirect at `/`.

View File

@@ -0,0 +1,26 @@
## Why
The app currently has no navigation — just a single page at `/`. Adding a sidebar drawer with navigation enables the app to grow to multiple pages while providing standard MudBlazor layout structure (AppBar + Drawer + MainContent).
## What Changes
- Add a MudDrawer to MainLayout with a NavMenu component containing a "Sales Assistant" link
- Add a hamburger toggle button in the AppBar to open/close the drawer
- Move the existing chat page from `/` to `/sales-assistant`
- Add a landing page or redirect at `/` so the app has a default route
## Capabilities
### New Capabilities
- `sidebar-navigation`: Collapsible sidebar drawer with navigation menu, hamburger toggle, and route structure
### Modified Capabilities
- `chat-ui`: Route changes from `/` to `/sales-assistant`
## Impact
- **MainLayout.razor**: Add MudDrawer, hamburger icon, drawer toggle state
- **New NavMenu component**: Shared/NavMenu.razor with MudNavGroup/MudNavLink items
- **Chat.razor**: Route changes from `@page "/"` to `@page "/sales-assistant"`
- **Chat.razor.css**: Height calc may need adjustment if AppBar Dense changes
- No new packages — MudDrawer/MudNavMenu are part of MudBlazor

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Chat page is default route
The chat page SHALL be routed at `/sales-assistant`. The root URL (`/`) SHALL redirect to `/sales-assistant`.
#### Scenario: App opens to chat via redirect
- **WHEN** the user navigates to the root URL `/`
- **THEN** the browser redirects to `/sales-assistant` and the chat page is displayed
#### Scenario: Direct navigation to sales-assistant
- **WHEN** the user navigates to `/sales-assistant`
- **THEN** the chat page is displayed

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Collapsible sidebar drawer
The application SHALL have a MudDrawer in MainLayout that contains a navigation menu. The drawer SHALL be toggleable via a hamburger icon button in the AppBar.
#### Scenario: Drawer visible on load
- **WHEN** the application loads
- **THEN** the sidebar drawer is displayed in its default open state with navigation links visible
#### Scenario: Drawer toggles on hamburger click
- **WHEN** the user clicks the hamburger icon in the AppBar
- **THEN** the drawer toggles between open and collapsed states
### Requirement: Navigation menu with Sales Assistant link
The sidebar drawer SHALL contain a MudNavMenu with a "Sales Assistant" navigation link that routes to `/sales-assistant`.
#### Scenario: Sales Assistant link present
- **WHEN** the drawer is open
- **THEN** a "Sales Assistant" link with a SmartToy icon is visible in the navigation menu
#### Scenario: Clicking Sales Assistant navigates to chat
- **WHEN** the user clicks the "Sales Assistant" link
- **THEN** the browser navigates to `/sales-assistant` and the chat page renders in MudMainContent
### Requirement: NavMenu is a separate component
The navigation menu SHALL be implemented as a separate `NavMenu.razor` component in the Layout folder, referenced from MainLayout.
#### Scenario: NavMenu renders inside drawer
- **WHEN** MainLayout renders
- **THEN** the NavMenu component renders inside the MudDrawer with its navigation links

View File

@@ -0,0 +1,29 @@
## 1. MainLayout: Add Drawer and Hamburger Toggle
- [x] 1.1 Add a `_drawerOpen` bool field (default `true`) and a `ToggleDrawer` method to MainLayout.razor
- [x] 1.2 Add a `MudIconButton` with `Icons.Material.Filled.Menu` as the first element in the MudAppBar, wired to `ToggleDrawer`
- [x] 1.3 Add a `MudDrawer` with `Open="@_drawerOpen"`, `ClipMode="DrawerClipMode.Always"`, `Elevation="2"` inside the MudLayout, before MudMainContent
- [x] 1.4 Reference `<NavMenu />` inside the MudDrawer
## 2. NavMenu Component
- [x] 2.1 Create `Layout/NavMenu.razor` with a `MudNavMenu` containing a single `MudNavLink` — text "Sales Assistant", icon `Icons.Material.Filled.SmartToy`, href `/sales-assistant`
- [x] 2.2 Add inline tutorial comments explaining MudNavMenu, MudNavLink, and how href-based navigation works in Blazor
## 3. Chat Page Route Change
- [x] 3.1 Change `Chat.razor` route from `@page "/"` to `@page "/sales-assistant"`
- [x] 3.2 Update the page title from "Chat Agent" to "Sales Assistant"
## 4. Root Redirect
- [x] 4.1 Create a `Pages/Index.razor` component at `@page "/"` that redirects to `/sales-assistant` on initialization using `NavigationManager.NavigateTo` (Home.razor already exists as health check page at /health)
## 5. Chat Container Height Adjustment
- [x] 5.1 Verify `Chat.razor.css` height calc still works with the drawer layout — Dense AppBar is 48px, drawer does not affect vertical space. Adjust if needed.
## 6. Verification
- [x] 6.1 Build the client project (`dotnet build`) and confirm no compilation errors
- [x] 6.2 Run existing tests (`dotnet test`) and confirm they pass

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@@ -0,0 +1,63 @@
## Context
The extraction endpoint (`POST /api/chat/extract`) and few-shot prompting infrastructure exist on the backend. The client needs a way to trigger extraction by uploading email files and then handle the multi-turn extraction conversation (disambiguation, result presentation).
The current Chat.razor has a text input with send button and Enter key handling. The extraction flow adds a new input modality (file drop) and conversation mode tracking.
## Goals / Non-Goals
**Goals:**
- Enable email file upload via drag-and-drop and file picker
- Route uploaded emails to the extraction endpoint
- Support disambiguation follow-up within the same conversation
- Present extraction results clearly in the chat stream
**Non-Goals:**
- .msg file parsing (MVP accepts .html only — users save emails as HTML first)
- Structured result UI (tables, editable fields) — the agent streams formatted text/markdown
- Offline/batch extraction of multiple emails
- Email preview or parsing on the client before sending to the API
## Decisions
### 1. Drag-and-drop on the message area, not a separate upload zone
**Decision:** The entire chat message area (`.message-list`) acts as the drop target, with visual feedback when a file is dragged over.
**Why:** Drag-and-drop onto the conversation area is the natural gesture — it mirrors how you'd "give" a document to someone in a chat. A separate upload widget adds visual clutter.
**Alternative considered:** Dedicated upload zone above the input area. Rejected — takes permanent screen space for an occasional action.
### 2. HTML files only for MVP
**Decision:** Accept `.html` files only. Do not support `.msg` parsing in this change.
**Why:** .msg is a proprietary Microsoft format requiring either a server-side library (like MsgReader) or a JS parser. HTML is what Outlook "Save As" produces and is trivially read via the File API. Supporting .msg can be a follow-up change.
### 3. Conversation mode tracking with `_isExtractionMode`
**Decision:** Add a boolean `_isExtractionMode` flag to Chat.razor. When an email is uploaded, set it to `true`. All subsequent `SendMessage()` calls route to the extraction endpoint (passing the original email HTML + growing message list). "New Chat" resets to `false`.
**Why:** After initial extraction, the user needs to reply to disambiguation questions. Those replies must go to the extraction endpoint with full context, not the general chat endpoint. The mode flag is the simplest routing mechanism.
**Alternative considered:** Separate extraction page/component. Rejected — breaks the conversational flow and duplicates the chat UI.
### 4. File reading via Blazor JS interop
**Decision:** Use `InputFile` component or JavaScript interop with the File API to read the dropped file's text content. Send the HTML string to the extraction endpoint.
**Why:** Blazor WASM has `InputFile` for file picker but drag-and-drop requires JS interop for the `drop` event. We need both: `InputFile` for the button, JS interop for drag-and-drop.
### 5. Agent streams results as markdown, no special result UI
**Decision:** The extraction agent's response (including the final JSON table) streams as markdown rendered by the existing rich text display. No special result component.
**Why:** The rich text rendering already handles tables, code blocks, and formatted output. The agent naturally presents results as a markdown table. A structured result component adds complexity without clear UX benefit at this stage.
## Risks / Trade-offs
**[Large email files]** → HTML emails with embedded images can be large. Mitigation: the API receives the HTML string only — embedded images are base64 in the HTML and the LLM will ignore them. If size becomes an issue, strip image tags client-side before sending.
**[Mode confusion]** → User may not realize they're in extraction mode. Mitigation: show a visual indicator (e.g., chip or banner) when `_isExtractionMode` is true, and include "New Chat" to reset.
**[Drop event handling in Blazor]** → Blazor's built-in event handling for drag-and-drop is limited. Mitigation: use a small JS interop function for the drop handler that reads the file and calls back into .NET.

View File

@@ -0,0 +1,29 @@
## Why
The extraction agent and few-shot prompting infrastructure exist on the backend, but the chat UI has no way to send emails to the extraction endpoint. Users need to drag-and-drop or upload email files (.html) to trigger extraction. The client must route email uploads to `POST /api/chat/extract` and handle the conversational extraction flow, including disambiguation questions from the agent and result presentation.
## What Changes
- **Add drag-and-drop zone** to `Chat.razor` that accepts email files (.html)
- **Add file picker button** as an alternative upload method
- **Route uploaded emails** to the extraction endpoint via `ChatApiClient.SendExtractionStreamingAsync()`
- **Handle extraction conversation flow** — initial extraction streams in, user can reply to disambiguation questions, follow-ups continue via the extraction endpoint
- **Present extraction results** — the agent's streamed response includes formatted output; optionally add a "Copy JSON" action
- **Track conversation mode** — after an email upload, subsequent messages route to the extraction endpoint until "New Chat" resets to general mode
## Capabilities
### New Capabilities
- `email-upload`: Defines the drag-and-drop upload zone, file handling, visual feedback, and supported formats
- `extraction-conversation-flow`: Defines the client-side conversation mode tracking, routing between general chat and extraction, and result presentation
### Modified Capabilities
- `chat-ui`: Add the upload zone to the chat input area and track conversation mode (general vs extraction)
## Impact
- **UI changes**: New drop zone and upload button in Chat.razor, visual feedback during drag-over
- **Chat.razor.css**: Styling for drop zone states (idle, drag-over, uploading)
- **ChatApiClient**: Already has `SendExtractionStreamingAsync` from the previous change — this change wires it to the UI
- **Conversation state**: New `_isExtractionMode` flag in Chat.razor to route messages correctly
- **Depends on**: `update-extraction-schema` and `few-shot-prompt-infrastructure` (extraction endpoint must exist)

View File

@@ -0,0 +1,30 @@
## MODIFIED Requirements
### Requirement: Message input
The chat page SHALL provide a text input area at the bottom of the page where the user can type and submit messages. The input area SHALL also include a file upload button for triggering email extraction.
#### Scenario: Submit via button
- **WHEN** the user types text and clicks the send button
- **THEN** the message is added to the conversation and the input is cleared
#### Scenario: Submit via Enter key
- **WHEN** the user types text and presses Enter
- **THEN** the message is submitted (same as clicking send)
#### Scenario: Empty input blocked
- **WHEN** the user attempts to send an empty or whitespace-only message
- **THEN** nothing is sent and no message is added
#### Scenario: Input disabled during streaming
- **WHEN** the assistant is currently streaming a response
- **THEN** the input field, send button, and upload button are disabled until streaming completes
#### Scenario: Upload button opens file picker
- **WHEN** the user clicks the upload button in the input area
- **THEN** a file picker dialog opens filtered to .html files

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Drag-and-drop email upload
The chat message area SHALL accept files dragged from the desktop or file explorer. When a supported file is dropped, the client SHALL read the file content and send it to the extraction endpoint.
#### Scenario: Drag HTML file onto chat
- **WHEN** the user drags an .html file over the message area
- **THEN** a visual drop indicator appears (e.g., highlighted border, overlay text "Drop email here")
#### Scenario: Drop HTML file triggers extraction
- **WHEN** the user drops an .html file onto the message area
- **THEN** the client reads the HTML content, sends it to `POST /api/chat/extract`, and streams the extraction response in the chat
#### Scenario: Unsupported file type rejected
- **WHEN** the user drops a non-.html file (e.g., .pdf, .docx)
- **THEN** the client shows a brief error message indicating only .html files are supported
### Requirement: File picker upload button
The chat input area SHALL include an upload button (e.g., attachment icon) that opens a file picker dialog for selecting .html email files.
#### Scenario: Upload via file picker
- **WHEN** the user clicks the upload button and selects an .html file
- **THEN** the client reads the HTML content and sends it to the extraction endpoint, same as drag-and-drop
### Requirement: Upload disabled during streaming
The upload zone and file picker SHALL be disabled while a response is streaming.
#### Scenario: Drop during streaming
- **WHEN** the user attempts to drop a file while the assistant is streaming
- **THEN** the drop is ignored and no extraction request is sent

View File

@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Extraction mode tracking
The chat page SHALL track whether the current conversation is in extraction mode. Extraction mode is entered when an email is uploaded and exited when the user starts a new chat.
#### Scenario: Enter extraction mode on upload
- **WHEN** the user uploads an email file
- **THEN** the conversation enters extraction mode and subsequent messages are routed to the extraction endpoint
#### Scenario: Exit extraction mode on New Chat
- **WHEN** the user clicks "New Chat" while in extraction mode
- **THEN** the conversation exits extraction mode and returns to general chat routing
### Requirement: Extraction mode visual indicator
The chat page SHALL display a visual indicator when in extraction mode so the user knows their messages are part of an extraction conversation.
#### Scenario: Indicator shown in extraction mode
- **WHEN** the conversation is in extraction mode
- **THEN** a visual indicator (e.g., chip, banner, or subtitle) is visible showing the extraction context
#### Scenario: Indicator hidden in general mode
- **WHEN** the conversation is in general chat mode
- **THEN** no extraction indicator is shown
### Requirement: Follow-up messages route to extraction endpoint
In extraction mode, text messages typed by the user SHALL be sent to the extraction endpoint with the original email HTML and full conversation history, not to the general chat endpoint.
#### Scenario: User replies to disambiguation question
- **WHEN** the agent asks "Which legal entity?" and the user types "1"
- **THEN** the client sends an ExtractionRequest with the original email HTML plus all messages (assistant question + user reply) to `POST /api/chat/extract`
### Requirement: Email upload message in chat
When an email is uploaded, the chat SHALL display a user message indicating the upload (e.g., showing the filename) before the extraction response streams in.
#### Scenario: Upload message displayed
- **WHEN** the user drops "trade_request.html"
- **THEN** a user message appears in the chat like "[Uploaded: trade_request.html]" followed by the streaming extraction response

View File

@@ -0,0 +1,41 @@
## 1. Drag-and-Drop Infrastructure
- [x] 1.1 Add JS interop function for drag-and-drop file reading — a small JS function that listens for `dragover`/`drop` events on a given element, reads the dropped file as text, and invokes a .NET callback with the filename and content
- [x] 1.2 Add the JS interop script to `wwwroot/index.html` or a separate `.js` file referenced there
- [x] 1.3 Wire the drag-and-drop JS interop to the `.message-list` element in Chat.razor — register on `OnAfterRenderAsync`, dispose on component disposal
## 2. File Upload Button
- [x] 2.1 Add `MudIconButton` with attachment icon next to the send button in the input area
- [x] 2.2 Add hidden `InputFile` component accepting `.html` files, triggered by the icon button click
- [x] 2.3 Handle `InputFile.OnChange` — read the selected file content as string, trigger extraction
## 3. Drop Zone Visual Feedback
- [x] 3.1 Add `_isDragOver` boolean state to Chat.razor, toggled by dragenter/dragleave events from JS interop
- [x] 3.2 Add CSS class `.drag-over` to `.message-list` when `_isDragOver` is true — highlighted border, subtle overlay with "Drop email here" text
- [x] 3.3 Add `.drag-over` styles to Chat.razor.css
## 4. Extraction Mode and Routing
- [x] 4.1 Add `_isExtractionMode` boolean and `_emailHtml` string fields to Chat.razor
- [x] 4.2 When an email file is read (via drop or file picker): set `_isExtractionMode = true`, store email HTML in `_emailHtml`, add a user message showing "[Uploaded: filename.html]"
- [x] 4.3 Create `SendExtractionMessage()` method — builds `ExtractionRequest` with `_emailHtml` and conversation messages, calls `ChatApiClient.SendExtractionStreamingAsync()`, streams response into assistant message (same pattern as `SendMessage()`)
- [x] 4.4 Modify `SendMessage()` — if `_isExtractionMode`, build `ExtractionRequest` with `_emailHtml` + all messages and call the extraction endpoint instead of the chat endpoint
- [x] 4.5 On file drop/upload: call `SendExtractionMessage()` for the initial extraction
- [x] 4.6 Modify `NewChat()` to reset `_isExtractionMode = false` and `_emailHtml = ""`
## 5. Extraction Mode Indicator
- [x] 5.1 Add a `MudChip` or small banner below the tab header showing "Extraction Mode" when `_isExtractionMode` is true
- [x] 5.2 Style the indicator in Chat.razor.css
## 6. Guard Rails
- [x] 6.1 Reject non-.html files in both drop handler and InputFile handler — show a snackbar or inline message
- [x] 6.2 Disable drop zone and file picker during streaming (`_isStreaming` flag)
## 7. Build and Verify
- [x] 7.1 Build the solution (`dotnet build`) and confirm no compilation errors
- [x] 7.2 Run all tests (`dotnet test`) and confirm they pass

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-05

View File

@@ -0,0 +1,51 @@
## Context
The chat page currently has a single-panel layout: message list + input. The system prompt is absent (no system message in ChatHistory), and model parameters like temperature use Semantic Kernel defaults. For prompt engineering and debugging, these need to be editable in the UI without restarting the server.
## Goals / Non-Goals
**Goals:**
- Tabbed UI: Chat, System Prompt, Model Settings — all on the same page
- System prompt and model settings sent with each chat request
- Backend applies them to SK's ChatHistory and OpenAIPromptExecutionSettings
- Settings persist in the browser session (survive tab switches, not page reloads)
**Non-Goals:**
- Persisting settings to disk or server (future — save/load prompt profiles)
- Phase 2 prompt templates and few-shot examples (scoped in proposal, not implemented here)
- Changing the SSE streaming contract
## Decisions
### Tabbed layout using MudTabs
- Use `MudTabs` with `MudTabPanel` for each section: Chat, System Prompt, Model Settings
- **Alternative considered**: MudDrawer panels or separate pages. Rejected because tabs keep everything on one page — switching between prompt and chat should be instant with no navigation.
- The Chat tab contains the existing message list and input (unchanged)
- System Prompt tab: a `MudTextField` with `Lines="10"` for multi-line editing
- Model Settings tab: `MudNumericField` or `MudSlider` for Temperature (0.02.0), TopP (0.01.0), MaxTokens (14096)
### Settings sent per-request, not stored server-side
- `ChatRequest` gains optional `SystemPrompt` (string?) and `Settings` (ModelSettings?) properties
- Backend treats them as nullable — if absent, defaults apply (no system prompt, SK default temperature)
- This keeps the API stateless and avoids server-side session management
- **Alternative considered**: Server-side settings endpoint. Rejected — adds complexity for a single-user debugging tool.
### ModelSettings as a shared DTO
- New `ModelSettings.cs` in Shared/Models with `Temperature` (double?), `TopP` (double?), `MaxTokens` (int?)
- All fields nullable — only set values override defaults
- Maps directly to `OpenAIPromptExecutionSettings` properties on the backend
### System prompt applied as ChatHistory system message
- `chatHistory.AddSystemMessage(request.SystemPrompt)` as the first entry before user/assistant messages
- SK and OpenAI APIs treat the system message as behavioral instructions for the model
### Tab state persists in component fields
- `_systemPrompt` and `_modelSettings` are component-level fields, not per-tab
- Switching tabs doesn't reset values (MudTabs preserves panel content by default)
- Values are lost on page refresh — acceptable for a debugging tool
## Risks / Trade-offs
- **[Tab switch loses scroll position]** → MudTabs renders all panels but hides inactive ones, so scroll position in the chat tab is preserved
- **[Large system prompts inflate request size]** → Acceptable for single-user debugging; no size limit enforced
- **[Temperature/TopP interaction]** → Standard OpenAI behavior: setting both is allowed but not recommended. Show a note in the UI, don't enforce.

View File

@@ -0,0 +1,35 @@
## Why
When testing and debugging the AI chat agent, the system prompt and model parameters (temperature, top-p, max tokens) are hardcoded or absent. Exposing these in the UI lets the developer iterate on prompt engineering without restarting the server, and makes the app useful as a prompt testing workbench.
## What Changes
### Phase 1: Expose system prompt and model parameters
- Add tabbed UI to the chat page: **Chat** tab (existing conversation), **System Prompt** tab (editable text area), **Model Settings** tab (temperature, top-p, max tokens sliders/inputs)
- Extend the API contract: `ChatRequest` gains optional `SystemPrompt` and `ModelSettings` fields
- Backend applies the system prompt as the first message in ChatHistory and passes model settings to execution settings
### Phase 2: Prompt templates with few-shot examples (future)
- System prompt becomes a template with placeholder variables
- UI for adding few-shot input/output example pairs
- Template engine generates the final system prompt from template + examples
- *Phase 2 is scoped but NOT implemented in this change*
## Capabilities
### New Capabilities
- `prompt-settings-ui`: Tabbed interface for system prompt editing and model parameter controls
- `prompt-settings-api`: API contract extensions for system prompt and model parameters
### Modified Capabilities
- `chat-ui`: Chat page changes from single-panel to tabbed layout
- `chat-streaming`: API accepts optional system prompt and model settings in the request
## Impact
- **ChatRequest.cs** (Shared): Add `SystemPrompt` and `ModelSettings` properties
- **New ModelSettings.cs** (Shared): Temperature, TopP, MaxTokens model
- **ChatController.cs** (API): Apply system prompt to ChatHistory, pass model settings to execution settings
- **Chat.razor** (Client): Wrap in MudTabs, add System Prompt and Model Settings tab panels
- **ChatApiClient.cs** (Client): Pass new fields in requests
- No new packages — MudTabs, MudTextField, MudSlider are all part of MudBlazor

View File

@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Chat endpoint proxies to Responses API
The API backend SHALL expose `POST /api/chat` that accepts a `ChatRequest` containing messages, an optional system prompt, and optional model settings. The request is processed using a Semantic Kernel chat completion service. When a system prompt is provided, it SHALL be added as the first system message in the ChatHistory. When model settings are provided, non-null values SHALL be applied to the execution settings.
#### Scenario: Successful chat request with system prompt
- **WHEN** the client sends a POST to `/api/chat` with messages and a system prompt
- **THEN** the API creates a ChatHistory with the system prompt as the first message, followed by the conversation messages, and processes them through Semantic Kernel
#### Scenario: Successful chat request with model settings
- **WHEN** the client sends a POST to `/api/chat` with messages and model settings (e.g., Temperature=0.3)
- **THEN** the API applies the settings to OpenAIPromptExecutionSettings before calling the Semantic Kernel
#### Scenario: Successful chat request without optional fields
- **WHEN** the client sends a POST to `/api/chat` with only messages (no system prompt, no settings)
- **THEN** the API processes the request with default behavior (no system message, default execution settings)

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Chat page is default route
The chat page SHALL be routed at `/sales-assistant` (or `/` with redirect). The page content SHALL be wrapped in a MudTabs container with the conversation UI in the first tab panel.
#### Scenario: Page loads with chat tab active
- **WHEN** the user navigates to the chat page
- **THEN** the Chat tab is active showing the message list and input area
#### Scenario: Chat functionality unchanged
- **WHEN** the user sends a message from the Chat tab
- **THEN** the assistant response streams in exactly as before, with the same SSE contract and rendering behavior

View File

@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: ModelSettings shared model
The Shared project SHALL define a `ModelSettings` class with nullable properties: `Temperature` (double?), `TopP` (double?), `MaxTokens` (int?). Null values indicate "use server default".
#### Scenario: All fields null
- **WHEN** a ModelSettings instance has all null fields
- **THEN** the backend uses Semantic Kernel default values for all parameters
#### Scenario: Partial override
- **WHEN** a ModelSettings instance has Temperature set but TopP and MaxTokens null
- **THEN** only Temperature is overridden; other parameters use defaults
### Requirement: System prompt in chat request
The `ChatRequest` SHALL accept an optional `SystemPrompt` (string?) property. When present and non-empty, the backend SHALL insert it as the first system message in the ChatHistory before user/assistant messages.
#### Scenario: System prompt provided
- **WHEN** a ChatRequest includes a non-empty SystemPrompt
- **THEN** the ChatHistory starts with a system message containing that text, followed by the conversation messages
#### Scenario: System prompt absent
- **WHEN** a ChatRequest has a null or empty SystemPrompt
- **THEN** the ChatHistory contains only user and assistant messages (no system message)
### Requirement: Model settings in chat request
The `ChatRequest` SHALL accept an optional `Settings` (ModelSettings?) property. When present, the backend SHALL apply non-null values to `OpenAIPromptExecutionSettings` before calling the Semantic Kernel.
#### Scenario: Temperature override
- **WHEN** a ChatRequest includes Settings with Temperature = 0.5
- **THEN** the OpenAIPromptExecutionSettings.Temperature is set to 0.5
#### Scenario: No settings provided
- **WHEN** a ChatRequest has null Settings
- **THEN** the backend uses default OpenAIPromptExecutionSettings (only FunctionChoiceBehavior.Auto is set)

View File

@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: System prompt editor tab
The chat page SHALL include a "System Prompt" tab with a multi-line text area where the user can enter a system prompt. The system prompt value SHALL persist across tab switches within the same session.
#### Scenario: User enters a system prompt
- **WHEN** the user navigates to the System Prompt tab and types text
- **THEN** the text is stored in the component state and included in the next chat request
#### Scenario: System prompt survives tab switch
- **WHEN** the user enters a system prompt, switches to the Chat tab, then switches back
- **THEN** the system prompt text is unchanged
### Requirement: Model settings tab
The chat page SHALL include a "Model Settings" tab with controls for Temperature, TopP, and MaxTokens. Each control SHALL display its current value and allow adjustment within valid ranges.
#### Scenario: Temperature control
- **WHEN** the user adjusts the Temperature control
- **THEN** the value is constrained to 0.02.0 and included in the next chat request's settings
#### Scenario: TopP control
- **WHEN** the user adjusts the TopP control
- **THEN** the value is constrained to 0.01.0 and included in the next chat request's settings
#### Scenario: MaxTokens control
- **WHEN** the user sets the MaxTokens value
- **THEN** the value is constrained to 14096 and included in the next chat request's settings
#### Scenario: Default values
- **WHEN** the user has not changed any model settings
- **THEN** the controls show default values (Temperature: 1.0, TopP: 1.0, MaxTokens: empty/unset) and no overrides are sent to the API
### Requirement: Tabbed page layout
The chat page SHALL use MudTabs with three tab panels: "Chat" (the existing conversation UI), "System Prompt" (the prompt editor), and "Model Settings" (the parameter controls).
#### Scenario: Chat tab is default
- **WHEN** the page loads
- **THEN** the Chat tab is active and the conversation UI is displayed
#### Scenario: Tab switching
- **WHEN** the user clicks a different tab
- **THEN** the corresponding panel is displayed and the previous panel is hidden but retains its state

View File

@@ -0,0 +1,40 @@
## 1. Shared Models
- [x] 1.1 Create `ModelSettings.cs` in Shared/Models — `double? Temperature`, `double? TopP`, `int? MaxTokens`
- [x] 1.2 Add `string? SystemPrompt` and `ModelSettings? Settings` properties to `ChatRequest.cs`
## 2. API Backend
- [x] 2.1 Update `ChatController.Post()` — if `request.SystemPrompt` is non-empty, call `chatHistory.AddSystemMessage(request.SystemPrompt)` before adding user/assistant messages
- [x] 2.2 Update `ChatController.Post()` — if `request.Settings` is non-null, apply non-null Temperature, TopP, MaxTokens to `OpenAIPromptExecutionSettings`
## 3. Client UI — Tabbed Layout
- [x] 3.1 Wrap the existing Chat.razor content (chat-container div) inside `<MudTabs>` with three `<MudTabPanel>` elements: "Chat", "System Prompt", "Model Settings"
- [x] 3.2 Add component fields: `_systemPrompt` (string), `_temperature` (double?), `_topP` (double?), `_maxTokens` (int?)
## 4. System Prompt Tab
- [x] 4.1 Add a `MudTextField` with `Lines="10"`, `Variant="Variant.Outlined"`, bound to `_systemPrompt`, with placeholder text explaining what a system prompt does
- [x] 4.2 Include the `_systemPrompt` value in the `ChatRequest` built by `SendMessage()`
## 5. Model Settings Tab
- [x] 5.1 Add `MudNumericField<double?>` for Temperature (min 0.0, max 2.0, step 0.1) with label and helper text
- [x] 5.2 Add `MudNumericField<double?>` for TopP (min 0.0, max 1.0, step 0.1) with label and helper text
- [x] 5.3 Add `MudNumericField<int?>` for MaxTokens (min 1, max 4096) with label and helper text
- [x] 5.4 Include the model settings in the `ChatRequest` built by `SendMessage()`
## 6. Client Service
- [x] 6.1 Verify `ChatApiClient.SendChatStreamingAsync()` serializes the new `ChatRequest` fields correctly (SystemPrompt, Settings) — no changes expected since it already serializes the full object
## 7. Styling
- [x] 7.1 Adjust `Chat.razor.css` — the chat-container height calc needs to account for the MudTabs header height (~48px). The tabs header sits inside the content area below the AppBar.
## 8. Verification
- [x] 8.1 Build the solution (`dotnet build`) and confirm no compilation errors
- [x] 8.2 Run existing tests (`dotnet test`) and confirm they pass
- [x] 8.3 Update any tests that construct `ChatRequest` if the new nullable fields cause issues — no updates needed, nullable fields don't break existing tests

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@@ -0,0 +1,69 @@
## Context
The extraction agent (Semantic Kernel with auto-invoke tools) needs few-shot examples to reliably map sales emails to TradeItem JSON. The `update-extraction-schema` change provides the real schema and tools. This change adds the prompting infrastructure and a dedicated endpoint.
Up to 100 input/output example pairs are available. Research shows 3-5 well-chosen examples are optimal for few-shot prompting — more can degrade performance by consuming context and introducing noise.
## Goals / Non-Goals
**Goals:**
- Load curated few-shot examples from disk and assemble a reusable ChatHistory prefix
- Provide a fixed instruction template for extraction (not user-editable)
- Create a dedicated extraction endpoint with the correct prompt and tools
- Keep the general chat endpoint unchanged
**Non-Goals:**
- Dynamic example selection (RAG-like similarity matching) — future enhancement
- Email upload UI (separate change: `email-upload-ux`)
- Building or curating the actual example content (user provides these)
- Evaluation pipeline for the ~95 non-few-shot examples (future work)
## Decisions
### 1. Examples as conversation turns (not system prompt string)
**Decision:** Inject few-shot examples as alternating User/Assistant messages in the ChatHistory, after the system message and before the real email.
**Why:** Chat models treat conversation turns as prior context — the model "sees" the examples as things it already did correctly. This is more effective than embedding examples in the system prompt string, where they're treated as instructions rather than demonstrated behavior.
### 2. Examples loaded once at startup, cached as ChatHistory prefix
**Decision:** `FewShotService` reads example files at startup, builds a `ChatHistory` prefix (system message + example turns), and caches it as a singleton. Each extraction request clones this prefix and appends the real email.
**Why:** Example files don't change at runtime. Loading once avoids repeated disk I/O. Cloning the cached prefix is cheap (ChatHistory is a list of message objects).
**Alternative considered:** Load examples per-request. Rejected — unnecessary I/O for static data.
### 3. Instruction template as an embedded text file
**Decision:** Store the extraction instruction template as a text file at `examples/extraction/instruction-template.txt`, loaded by FewShotService alongside the examples.
**Why:** Keeps the prompt text editable without recompilation. Co-located with the examples it references. Not in appsettings.json because it's multi-line prose, not configuration.
### 4. Separate extraction endpoint, not a mode flag on /api/chat
**Decision:** `POST /api/chat/extract` as a new controller action, separate from `POST /api/chat`.
**Why:** The extraction path uses a completely different ChatHistory (few-shot prefix, not user system prompt), different tools (extraction plugins only), and a different request DTO. A mode flag on the existing endpoint would add branching complexity. Separate endpoints make each path clear.
**Alternative considered:** Mode flag on ChatRequest (e.g. `"mode": "extract"`). Rejected — the request shapes diverge enough to warrant separate DTOs and endpoints.
### 5. ExtractionRequest includes conversation messages for follow-up
**Decision:** `ExtractionRequest` contains `EmailHtml` (string, the email to extract) plus `Messages` (list, optional follow-up conversation for disambiguation).
**Why:** After initial extraction, the agent may ask disambiguation questions. Follow-up user replies need to be sent back with the full conversation context so the agent can continue. The first request has only `EmailHtml`; subsequent requests include the growing `Messages` list.
### 6. Example folder uses numbered subdirectories
**Decision:** `examples/extraction/few-shot/01/input.html + output.json`, `02/`, etc.
**Why:** Numbered prefixes control ordering in the ChatHistory. Each subdirectory is one example, keeping input and output together. Easy to add/remove/reorder examples by renaming directories.
## Risks / Trade-offs
**[Example quality determines extraction quality]** → Poorly chosen few-shot examples will mislead the model. Mitigation: document selection criteria (diversity of swap structures, currencies, breakclause values). The user curates the 3-5 examples.
**[Instruction template drift]** → If the schema changes, the instruction template must be updated manually. Mitigation: the template references the TradeItem field names explicitly, making it obvious when they're out of sync.
**[ChatHistory size with few-shot examples]** → Each example adds ~2-5KB of tokens. With 5 examples that's ~10-25KB, well within model context limits. Not a risk at current scale but would be if dynamic selection adds more examples later.

View File

@@ -0,0 +1,31 @@
## Why
The extraction agent needs few-shot examples to reliably produce correct structured output from sales emails. Without examples, the agent relies entirely on the instruction template and tool descriptions, which cannot fully convey the implicit mapping conventions (date parsing from "OB" prefix, flattening swap legs, currency symbol mapping, breakclause defaults). A curated set of 3-5 input/output examples injected as conversation turns in the ChatHistory dramatically improves extraction accuracy. The remaining ~95 available examples serve as an evaluation set for offline quality testing.
Additionally, the extraction workflow needs a dedicated API endpoint separate from general chat, since it uses a different system prompt, different tools, and the few-shot ChatHistory prefix.
## What Changes
- **Create examples folder structure** at `examples/extraction/few-shot/` with numbered subdirectories, each containing `input.html` (email) and `output.json` (expected ExtractionResult)
- **Create extraction instruction template** — a fixed system prompt defining the extraction task, schema, and mapping rules (separate from the user-editable system prompt)
- **Create a FewShotService** that loads examples from disk at startup and pre-assembles a ChatHistory prefix (system message + alternating user/assistant turns)
- **Add `POST /api/chat/extract` endpoint** that uses the few-shot ChatHistory, appends the real email, and streams the extraction response via SSE
- **Create `ExtractionRequest` DTO** for the extraction endpoint (email content + optional follow-up messages for disambiguation)
- **Update client `ChatApiClient`** with a method for the extraction endpoint
## Capabilities
### New Capabilities
- `few-shot-prompting`: Defines the example folder structure, loading mechanism, ChatHistory assembly, and instruction template for few-shot extraction prompting
- `extraction-endpoint`: Defines the dedicated extraction API endpoint, its request/response contract, and how it differs from the general chat endpoint
### Modified Capabilities
- `chat-streaming`: Add the extraction endpoint alongside the existing chat endpoint, sharing the same SSE streaming contract
## Impact
- **New files**: examples folder, FewShotService, instruction template, ExtractionRequest DTO, extraction controller action
- **Configuration**: example folder path in appsettings.json
- **API surface**: new `POST /api/chat/extract` endpoint
- **Client**: new method on ChatApiClient (no UI changes — that's the email-upload-ux change)
- **Depends on**: `update-extraction-schema` (needs TradeItem schema for examples and validation tools)

View File

@@ -0,0 +1,25 @@
## MODIFIED Requirements
### Requirement: Chat endpoint proxies to Responses API
The API backend SHALL expose `POST /api/chat` that accepts a `ChatRequest` containing messages, an optional system prompt, and optional model settings. The request is processed using a Semantic Kernel chat completion service. When a system prompt is provided, it SHALL be added as the first system message in the ChatHistory. When model settings are provided, non-null values SHALL be applied to the execution settings. A separate `POST /api/chat/extract` endpoint SHALL handle extraction-specific requests with few-shot prompting.
#### Scenario: Successful chat request with system prompt
- **WHEN** the client sends a POST to `/api/chat` with messages and a system prompt
- **THEN** the API creates a ChatHistory with the system prompt as the first message, followed by the conversation messages, and processes them through Semantic Kernel
#### Scenario: Successful chat request with model settings
- **WHEN** the client sends a POST to `/api/chat` with messages and model settings (e.g., Temperature=0.3)
- **THEN** the API applies the settings to OpenAIPromptExecutionSettings before calling the Semantic Kernel
#### Scenario: Successful chat request without optional fields
- **WHEN** the client sends a POST to `/api/chat` with only messages (no system prompt, no settings)
- **THEN** the API processes the request with default behavior (no system message, default execution settings)
#### Scenario: Extraction request routed to dedicated endpoint
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML
- **THEN** the API uses the few-shot ChatHistory prefix and extraction tools instead of the general chat configuration

View File

@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Extraction API endpoint
The API SHALL expose `POST /api/chat/extract` that accepts an `ExtractionRequest` containing the email HTML content and optional follow-up conversation messages. The endpoint SHALL use the few-shot ChatHistory prefix (not the user-editable system prompt) and load extraction-specific SK plugins.
#### Scenario: Initial extraction request
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML and no follow-up messages
- **THEN** the API assembles the few-shot ChatHistory, appends the email as the final user message, and streams the extraction response via SSE
#### Scenario: Follow-up disambiguation request
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML and follow-up messages (e.g., user selecting a counterparty)
- **THEN** the API assembles the few-shot ChatHistory, appends the email, appends all follow-up messages, and streams the continuation response via SSE
#### Scenario: SSE streaming contract
- **WHEN** the extraction endpoint streams a response
- **THEN** it uses the same SSE format as `/api/chat`: `data: {"text":"..."}\n\n` for deltas and `data: [DONE]\n\n` for completion
### Requirement: ExtractionRequest DTO
The system SHALL define an `ExtractionRequest` class with `EmailHtml` (string, required) and `Messages` (List<ChatMessage>, optional) for follow-up conversation context.
#### Scenario: First request has email only
- **WHEN** the user uploads an email for the first time
- **THEN** the ExtractionRequest contains `EmailHtml` with the email content and an empty `Messages` list
#### Scenario: Follow-up request includes conversation
- **WHEN** the user replies to a disambiguation question
- **THEN** the ExtractionRequest contains the original `EmailHtml` plus `Messages` with the full assistant/user exchange since the extraction started
### Requirement: Extraction endpoint uses extraction tools only
The extraction endpoint SHALL import only the extraction-specific SK plugins (counterparty lookup, trade validation, currency validation, schema validation). General chat tools (if any) SHALL NOT be loaded for extraction requests.
#### Scenario: Tool isolation
- **WHEN** the extraction endpoint processes a request
- **THEN** only extraction-related KernelFunctions are available to the LLM

View File

@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Few-shot example folder structure
The system SHALL store few-shot examples at `examples/extraction/few-shot/` with numbered subdirectories (e.g., `01/`, `02/`). Each subdirectory SHALL contain `input.html` (the example email) and `output.json` (the expected ExtractionResult JSON).
#### Scenario: Example folder layout
- **WHEN** the application starts
- **THEN** it reads example pairs from `examples/extraction/few-shot/` in numeric directory order
#### Scenario: Adding a new example
- **WHEN** a new subdirectory (e.g., `04/`) is added with `input.html` and `output.json`
- **THEN** the new example is included in the few-shot ChatHistory prefix after the next application restart
### Requirement: Extraction instruction template
The system SHALL load a fixed instruction template from `examples/extraction/instruction-template.txt` that defines the extraction task, the TradeItem schema, and the mapping rules (date parsing, leg flattening, currency mapping, breakclause defaults). This template is NOT the user-editable system prompt.
#### Scenario: Template loaded at startup
- **WHEN** the application starts
- **THEN** the instruction template is loaded from disk and used as the system message in the extraction ChatHistory
#### Scenario: Template content
- **WHEN** the instruction template is loaded
- **THEN** it contains the TradeItem field definitions, expected JSON output format, and explicit mapping rules
### Requirement: ChatHistory assembly with few-shot examples
The system SHALL provide a `FewShotService` that assembles a reusable ChatHistory prefix at startup: the instruction template as a system message, followed by alternating User (input.html) and Assistant (output.json) messages for each example. Each extraction request SHALL clone this prefix and append the real email as the final user message.
#### Scenario: ChatHistory prefix structure
- **WHEN** the service assembles the prefix with 3 examples
- **THEN** the ChatHistory contains: 1 system message + 3 user messages + 3 assistant messages (7 messages total)
#### Scenario: Prefix cached and cloned per request
- **WHEN** an extraction request arrives
- **THEN** the service clones the cached prefix (not re-reading from disk) and appends the email content as a new user message
### Requirement: Evaluation example folder
The system SHALL support an `examples/extraction/evaluation/` folder for bulk examples used in offline testing. This folder is NOT loaded at startup and NOT used in the few-shot prompt.
#### Scenario: Evaluation folder ignored at runtime
- **WHEN** the application starts
- **THEN** it does not load examples from `examples/extraction/evaluation/`

View File

@@ -0,0 +1,36 @@
## 1. Example Folder Structure
- [x] 1.1 Create directory structure: `examples/extraction/few-shot/` and `examples/extraction/evaluation/`
- [x] 1.2 Add placeholder example `01/` with `input.html` (sample email HTML) and `output.json` (sample ExtractionResult JSON matching TradeItem schema). Use realistic but anonymized data.
- [x] 1.3 Add placeholder example `02/` with a different email pattern (e.g., single swap, different currency)
- [x] 1.4 Add placeholder example `03/` with an edge case (e.g., breakclause = "Y", or unusual counterparty name)
## 2. Instruction Template
- [x] 2.1 Create `examples/extraction/instruction-template.txt` with the fixed extraction system prompt: task description, TradeItem schema definition (all 7 fields with types), mapping rules (date parsing, leg flattening, currency symbol → ISO code, breakclause default), and expected JSON output format
## 3. FewShotService
- [x] 3.1 Create `FewShotService.cs` in the API project — constructor loads instruction template and all few-shot examples from disk, assembles a ChatHistory prefix (system message + alternating user/assistant turns)
- [x] 3.2 Add `CloneWithEmail(string emailHtml)` method that clones the cached ChatHistory prefix and appends the email as a user message
- [x] 3.3 Add `CloneWithEmailAndMessages(string emailHtml, List<ChatMessage> messages)` method for follow-up disambiguation requests — clones prefix, appends email, appends follow-up messages
- [x] 3.4 Register `FewShotService` as singleton in `Program.cs`
- [x] 3.5 Add `Examples:FewShotPath` configuration in `appsettings.json` pointing to the examples folder
## 4. ExtractionRequest DTO
- [x] 4.1 Create `ExtractionRequest.cs` in Shared/Models with `EmailHtml` (string, required) and `Messages` (List<ChatMessage>, optional)
## 5. Extraction Endpoint
- [x] 5.1 Add `Extract` action to `ChatController` (or new `ExtractionController`) — `POST /api/chat/extract` accepting `ExtractionRequest`
- [x] 5.2 In the Extract action: get ChatHistory from `FewShotService.CloneWithEmail()` or `CloneWithEmailAndMessages()` based on whether Messages are present
- [x] 5.3 Import extraction-specific plugins only (not general chat plugins)
- [x] 5.4 Stream response via SSE using the same format as the existing chat endpoint
- [x] 5.5 Update `ChatApiClient` on the client side — add `SendExtractionStreamingAsync(ExtractionRequest request)` method mirroring the existing streaming pattern
## 6. Build and Verify
- [x] 6.1 Build the solution (`dotnet build`) and confirm no compilation errors
- [x] 6.2 Run all tests (`dotnet test`) and confirm they pass
- [x] 6.3 Add unit test for `FewShotService` — verify it loads examples and assembles correct ChatHistory structure (message count, roles, ordering)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@@ -0,0 +1,73 @@
## Context
The extraction pipeline currently uses a placeholder schema (`ExtractedFields` with Client, Project, Hours, Rate, Currency, Date) and a single in-process validation method. The real domain is CVA trade extraction from sales emails: HTML emails containing swap/leg tables need to be parsed into an array of `TradeItem` objects. Validation and enrichment require calling 3-5 existing external APIs (counterparty lookup, trade validation, etc.), some of which return multiple candidates requiring user disambiguation.
The `ExtractionPlugin` is already registered with Semantic Kernel and auto-invoked via `FunctionChoiceBehavior.Auto()`. The tool calling loop and SSE streaming infrastructure are in place.
## Goals / Non-Goals
**Goals:**
- Replace placeholder models with the real TradeItem schema
- Replace single validation method with multiple external API tool wrappers
- Support disambiguation workflow where tool results return candidate lists
- Keep the external API integration configurable and testable
**Non-Goals:**
- Few-shot prompting infrastructure (separate change: `few-shot-prompt-infrastructure`)
- Email upload/drag-drop UX (separate change: `email-upload-ux`)
- Building the external APIs themselves (they already exist)
- Changing the SSE streaming contract or client-side rendering
## Decisions
### 1. Schema structure: wrapper with items array
**Decision:** Use `ExtractionResult { List<TradeItem> Items }` rather than returning a flat `TradeItem`.
**Why:** A single email typically contains multiple swaps, each with multiple legs. Each leg becomes a separate `TradeItem`. The wrapper allows returning all items from one extraction pass.
**Alternative considered:** Returning `List<TradeItem>` directly. Rejected because a wrapper object is more extensible (could add metadata like extraction confidence, email subject, etc. later) and produces cleaner JSON (`{"items": [...]}` vs bare array).
### 2. TradeItem uses snake_case JSON property names
**Decision:** Use `[JsonPropertyName("valuedate")]` etc. to produce snake_case JSON output.
**Why:** The existing external APIs and downstream consumers expect snake_case. The C# properties will use PascalCase per convention, with JSON attributes for serialization.
### 3. One plugin class with multiple methods vs multiple plugin classes
**Decision:** Single `ExtractionPlugin` class with 3-5 `[KernelFunction]` methods.
**Why:** All tools serve the same extraction workflow. SK discovers functions by class, so a single class keeps registration simple (`ImportPluginFromObject`). If tools grow beyond 6-7, split into focused plugin classes.
**Alternative considered:** Separate plugin class per external API. Rejected as premature — adds registration complexity for no benefit at 3-5 methods.
### 4. External API calls via typed HttpClients
**Decision:** Register typed `HttpClient` instances for each external API via `AddHttpClient<T>()` in `Program.cs`. Each plugin method receives its client via constructor injection.
**Why:** Typed HttpClients are the standard ASP.NET Core pattern. They support per-client base URL configuration, DI, and are easily mockable in tests.
### 5. Disambiguation via tool return values, not special UI
**Decision:** When a tool like `lookup_counterparty` finds multiple matches, it returns the candidate list as JSON. The LLM agent sees the candidates and asks the user to choose via natural language in the streamed response.
**Why:** This uses the existing streaming conversation infrastructure with zero client changes. The agent is already conversational — it simply says "I found 3 matches, which one?" and the user replies.
**Alternative considered:** Structured disambiguation UI (radio buttons, dropdowns). Rejected for this change — adds client complexity and a new response type. Can be added later as a UX enhancement.
### 6. ExtractionPlugin conditionally loaded per request
**Decision:** Continue importing extraction plugins per-request in the controller (`_kernel.ImportPluginFromObject`), not at startup.
**Why:** General chat requests don't need extraction tools. Per-request import keeps the tool list clean for non-extraction conversations, reducing token usage and avoiding confusing the LLM with irrelevant tools.
## Risks / Trade-offs
**[External API availability]** → Plugin methods should handle HTTP errors gracefully and return structured error responses that the LLM can reason about (e.g., "Counterparty API unavailable, proceeding without legal entity lookup"). The agent can then inform the user.
**[Breaking change to ExtractedFields]** → Any code referencing the old schema breaks. Mitigation: this is a small codebase with known consumers. Update all references in the same change. Tests will catch missed spots.
**[LLM disambiguation quality]** → The agent must correctly interpret candidate lists and present clear choices. Mitigation: tool descriptions must be explicit about what the return values mean. Few-shot examples (next change) will reinforce the pattern.
**[External API response format coupling]** → Plugin methods parse external API responses. If those APIs change, plugins break. Mitigation: typed response DTOs with defensive deserialization. External APIs are stable and owned by the same team.

View File

@@ -0,0 +1,28 @@
## Why
The current extraction schema (`ExtractedFields`) uses placeholder fields (Client, Project, Hours, Rate, Currency, Date) that don't match the real domain. The actual use case is CVA (Credit Valuation Adjustment) trade extraction from sales emails — parsing HTML emails into structured trade items with fields like counterparty, trade_id, pv, and legal_entity. The single `ExtractionPlugin.ValidateExtractedFields()` method also needs to be replaced with multiple tools that wrap existing external APIs for counterparty lookup, trade validation, and other checks.
## What Changes
- **Replace `ExtractedFields.cs`** with real domain models: `ExtractionResult` (wrapper) and `TradeItem` (per-trade fields: valuedate, counterparty, legal_entity, trade_id, display_ccy, pv, breakclause)
- **Replace `ExtractionPlugin.cs`** single validation method with 3-5 SK plugin methods, each wrapping an existing external API (counterparty lookup, trade validation, currency validation, schema validation)
- **Update `ValidationResult.cs`** to support richer results — candidate lists for disambiguation, not just pass/fail
- **Add typed HttpClients** for the external validation/lookup APIs, configured via `appsettings.json`
- **Update existing tests** that reference the old `ExtractedFields` and `ExtractionPlugin`
## Capabilities
### New Capabilities
- `extraction-schema`: Defines the real TradeItem schema, ExtractionResult wrapper, and the mapping rules from email content to structured output (date format, flattening swap legs, breakclause defaults)
- `extraction-tools`: Defines the external API tool plugins — counterparty lookup (with disambiguation), trade validation, currency validation, and final schema validation
### Modified Capabilities
- `agent-extraction`: Update requirements to reference the real schema (TradeItem) instead of generic "predefined fields", and add disambiguation workflow where tool results require user selection (e.g., counterparty/legal_entity tuples)
## Impact
- **Shared models**: `ExtractedFields.cs` replaced — **BREAKING** for any code referencing old fields
- **API plugins**: `ExtractionPlugin.cs` rewritten with new method signatures — **BREAKING** for existing tool calling behavior
- **External dependencies**: New HTTP calls to existing external APIs (counterparty, trade, currency)
- **Configuration**: New `appsettings.json` entries for external API base URLs
- **Tests**: Existing extraction-related tests need rewriting against new schema and tools

View File

@@ -0,0 +1,53 @@
## MODIFIED Requirements
### Requirement: Predefined extraction schema
The system SHALL define the extraction schema as a `TradeItem` class with fields: valuedate, counterparty, legal_entity, trade_id, display_ccy, pv, breakclause. Extraction output SHALL be wrapped in an `ExtractionResult` containing a `List<TradeItem>`. All extraction output MUST conform to this schema.
#### Scenario: Output conforms to schema
- **WHEN** the agent produces extracted fields from an email
- **THEN** every item in the output is a valid TradeItem with all required fields matching expected types
#### Scenario: Multiple items from one email
- **WHEN** the agent extracts data from an email containing multiple trade legs
- **THEN** the output ExtractionResult contains one TradeItem per trade leg
### Requirement: Autonomous validation via tool calling
The agent SHALL validate extracted fields by calling external API tools exposed as Semantic Kernel functions. Validation tools include counterparty lookup, trade validation, currency validation, and schema validation. Each tool returns structured results that the agent reasons about.
#### Scenario: Validation passes
- **WHEN** the agent calls the schema validation tool with a complete and correct ExtractionResult
- **THEN** the tool returns a success result and the agent returns the final output to the user
#### Scenario: Validation fails with fixable errors
- **WHEN** a validation tool returns errors for missing or malformed fields
- **THEN** the agent re-reads the source text and attempts to fix the extraction without user intervention
#### Scenario: Counterparty disambiguation required
- **WHEN** the counterparty lookup tool returns multiple candidate (counterparty, legal_entity) tuples
- **THEN** the agent presents the candidates to the user as a numbered list in the chat and waits for the user to select one before completing the extraction
### Requirement: Human-in-the-loop clarification
When the agent escalates to the user, the user SHALL be able to provide the missing information in natural language, and the agent SHALL incorporate the clarification and re-attempt extraction. Disambiguation of counterparty/legal_entity tuples is a specific case of human-in-the-loop clarification.
#### Scenario: User provides clarification
- **WHEN** the agent asks for clarification about missing fields and the user responds
- **THEN** the agent incorporates the user's response into the conversation context and produces an updated extraction
#### Scenario: User selects counterparty from candidates
- **WHEN** the agent presents a numbered list of counterparty/legal_entity candidates and the user replies with a selection
- **THEN** the agent populates the `legal_entity` field on all relevant TradeItems and proceeds with validation
#### Scenario: Clarification via normal chat
- **WHEN** the agent escalates for clarification
- **THEN** the clarification request appears as a regular assistant message in the chat UI, and the user responds via the normal chat input

View File

@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: TradeItem schema
The system SHALL define a `TradeItem` class with the following fields representing a single trade leg extracted from a sales email:
- `valuedate` (string, dd/MM/yyyy format)
- `counterparty` (string, full legal name as it appears in the email)
- `legal_entity` (string, nullable — populated after counterparty disambiguation via lookup tool)
- `trade_id` (long, Murex trade identifier)
- `display_ccy` (string, ISO currency code e.g. "GBP", "USD")
- `pv` (double, present value)
- `breakclause` (string, "Y" or "N")
JSON serialization SHALL use snake_case property names via `[JsonPropertyName]` attributes.
#### Scenario: All fields populated
- **WHEN** the extraction agent produces a TradeItem with all fields
- **THEN** the JSON output contains all seven fields with snake_case keys and correct types
#### Scenario: Legal entity null before disambiguation
- **WHEN** the extraction agent produces a TradeItem before counterparty lookup
- **THEN** the `legal_entity` field is null and all other fields are populated
### Requirement: ExtractionResult wrapper
The system SHALL define an `ExtractionResult` class containing a `List<TradeItem> Items` property. All extraction output from a single email SHALL be wrapped in this object.
#### Scenario: Single email with multiple trade legs
- **WHEN** an email contains two swaps with two legs each (4 trades total)
- **THEN** the ExtractionResult contains an `items` array with 4 TradeItem objects
#### Scenario: JSON output structure
- **WHEN** the ExtractionResult is serialized to JSON
- **THEN** the output has the shape `{"items": [{"valuedate": "...", ...}, ...]}`
### Requirement: Extraction mapping rules
The extraction agent SHALL follow these mapping rules when converting email content to TradeItems:
- Each swap leg (identified by a unique Murex trade ID) becomes a separate TradeItem
- The `valuedate` SHALL be parsed from date references in the email (e.g., "OB 27/11/2025") and formatted as dd/MM/yyyy
- The `counterparty` SHALL be the full legal entity name as stated in the email prose
- The `display_ccy` SHALL be derived from the currency symbol or code in the email (e.g., "£" or "PV (£)" → "GBP")
- The `breakclause` SHALL default to "N" if not explicitly mentioned in the email
- The `pv` SHALL be the numeric present value without formatting (no commas, no currency symbols)
#### Scenario: Flatten multi-leg swap into individual items
- **WHEN** the email contains a swap with Coupon Leg (Murex 79353083) and APD leg (Murex 79353084)
- **THEN** the output contains two separate TradeItems, one per Murex ID
#### Scenario: Currency symbol to ISO code mapping
- **WHEN** the email shows PV values in "PV (£)" column
- **THEN** the `display_ccy` field is set to "GBP"
#### Scenario: Default breakclause
- **WHEN** the email does not mention break clauses
- **THEN** all TradeItems have `breakclause` set to "N"

View File

@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Counterparty lookup tool
The extraction plugin SHALL expose a `lookup_counterparty` Semantic Kernel function that accepts a counterparty name string and calls the external counterparty API. The tool SHALL return a list of candidate (counterparty, legal_entity) tuples.
#### Scenario: Single match found
- **WHEN** the tool is called with a counterparty name that matches exactly one record
- **THEN** the tool returns a single candidate with the counterparty name and legal entity ID
#### Scenario: Multiple matches found (disambiguation needed)
- **WHEN** the tool is called with a counterparty name that matches multiple records
- **THEN** the tool returns all matching candidates so the agent can present them to the user for selection
#### Scenario: No match found
- **WHEN** the tool is called with a counterparty name that matches no records
- **THEN** the tool returns an empty list and an informative message so the agent can ask the user for clarification
### Requirement: Trade validation tool
The extraction plugin SHALL expose a `validate_trade` Semantic Kernel function that accepts a trade ID and calls the external trade validation API to verify the trade exists.
#### Scenario: Valid trade ID
- **WHEN** the tool is called with a known trade ID
- **THEN** the tool returns a success result confirming the trade exists
#### Scenario: Invalid trade ID
- **WHEN** the tool is called with an unknown trade ID
- **THEN** the tool returns an error result so the agent can flag it to the user
### Requirement: Currency validation tool
The extraction plugin SHALL expose a `validate_currency` Semantic Kernel function that accepts a currency code and calls the external currency validation API to verify it is a valid ISO currency code.
#### Scenario: Valid currency code
- **WHEN** the tool is called with "GBP"
- **THEN** the tool returns a success result
#### Scenario: Invalid currency code
- **WHEN** the tool is called with an unrecognized code
- **THEN** the tool returns an error with suggestions for valid codes
### Requirement: Schema validation tool
The extraction plugin SHALL expose a `validate_schema` Semantic Kernel function that accepts the full ExtractionResult JSON and validates that all required fields are present and correctly typed for every TradeItem.
#### Scenario: Valid extraction result
- **WHEN** the tool is called with a complete and correctly typed ExtractionResult JSON
- **THEN** the tool returns a success result with no errors
#### Scenario: Missing required fields
- **WHEN** the tool is called with a TradeItem missing the `trade_id` field
- **THEN** the tool returns a failure result listing the missing fields and which item they belong to
### Requirement: External API configuration
All external API base URLs SHALL be configurable via `appsettings.json` under an `ExternalApis` section. Each tool's HttpClient SHALL read its base URL from configuration at startup.
#### Scenario: Configuration at startup
- **WHEN** the API starts
- **THEN** it reads external API base URLs from the `ExternalApis` configuration section and configures typed HttpClients accordingly
### Requirement: External API error handling
Each tool SHALL handle HTTP errors from external APIs gracefully, returning a structured error message that the LLM agent can reason about rather than throwing exceptions.
#### Scenario: External API unavailable
- **WHEN** a tool calls an external API that is unreachable
- **THEN** the tool returns an error result with a descriptive message (e.g., "Counterparty API unavailable") so the agent can inform the user

View File

@@ -0,0 +1,38 @@
## 1. Replace Shared Models
- [x] 1.1 Create `TradeItem.cs` in Shared/Models with fields: `Valuedate` (string), `Counterparty` (string), `LegalEntity` (string?), `TradeId` (long), `DisplayCcy` (string), `Pv` (double), `Breakclause` (string). Add `[JsonPropertyName]` attributes for snake_case serialization.
- [x] 1.2 Create `ExtractionResult.cs` in Shared/Models with `List<TradeItem> Items` property
- [x] 1.3 Delete the old `ExtractedFields.cs`
- [x] 1.4 Update `ValidationResult.cs` to support candidate lists — add `List<CandidateMatch>? Candidates` property for disambiguation results (each with `Name` and `LegalEntity` fields)
## 2. Configure External API HttpClients
- [x] 2.1 Add `ExternalApis` section to `appsettings.json` with base URLs for counterparty, trade, and currency APIs
- [x] 2.2 Create typed HttpClient service `CounterpartyApiClient` with `LookupAsync(string name)` method returning candidate tuples
- [x] 2.3 Create typed HttpClient service `TradeApiClient` with `ValidateAsync(long tradeId)` method
- [x] 2.4 Create typed HttpClient service `CurrencyApiClient` with `ValidateAsync(string currencyCode)` method
- [x] 2.5 Register all typed HttpClients in `Program.cs` via `AddHttpClient<T>()` with base URLs from configuration
## 3. Rewrite ExtractionPlugin
- [x] 3.1 Replace `ExtractionPlugin.ValidateExtractedFields()` with `LookupCounterparty(string name)` — calls `CounterpartyApiClient`, returns candidate list as JSON. Include `[KernelFunction]` and `[Description]` attributes explaining the tool returns candidates for disambiguation.
- [x] 3.2 Add `ValidateTrade(long tradeId)` method — calls `TradeApiClient`, returns valid/invalid result as JSON
- [x] 3.3 Add `ValidateCurrency(string currencyCode)` method — calls `CurrencyApiClient`, returns valid/invalid result as JSON
- [x] 3.4 Add `ValidateSchema(string extractionResultJson)` method — validates the full ExtractionResult JSON against TradeItem schema locally (required fields present, correct types, breakclause is Y or N)
- [x] 3.5 Inject typed HttpClients into ExtractionPlugin via constructor. Register ExtractionPlugin in DI with its dependencies.
- [x] 3.6 Add error handling in each tool method — catch `HttpRequestException`, return structured error JSON instead of throwing
## 4. Update Controller
- [x] 4.1 Update `ChatController.Post()` — no changes needed, DI resolution via GetRequiredService works with scoped registration to use the new `ExtractionPlugin` constructor (pass DI-resolved instance with HttpClients)
## 5. Update Tests
- [x] 5.1 Remove or rewrite tests referencing old `ExtractedFields` schema
- [x] 5.2 Add unit tests for `ValidateSchema` method against TradeItem (valid, missing fields, wrong types)
- [x] 5.3 Add unit tests for ExtractionPlugin tool methods with mocked HttpClients (success, error, multi-candidate responses)
## 6. Build and Verify
- [x] 6.1 Build the solution (`dotnet build`) and confirm no compilation errors
- [x] 6.2 Run all tests (`dotnet test`) and confirm they pass

View File

@@ -0,0 +1,131 @@
# OpenSpec Artifacts for: AI Chat Agent with Streaming & Rich Text
On the target machine with OpenSpec + Claude Code:
1. Create `openspec/config.yaml` (see below)
2. Save `proposal.md` to `openspec/changes/chat-agent-full/proposal.md`
3. Save `design.md` to `openspec/changes/chat-agent-full/design.md`
4. Save `tasks.md` to `openspec/changes/chat-agent-full/tasks.md`
5. Run `/opsx:apply chat-agent-full`
6. Use the portable spec (`chat-agent-full-spec.md`) as reference if the AI needs detail
---
## config.yaml
Adapt to your project name and constraints, save as `openspec/config.yaml`:
```yaml
schema: spec-driven
context: |
<Your Project Name> — <one-line description>.
Tech stack: .NET 9, C# 13, Blazor WASM, ASP.NET Core Web API.
Key libraries: MudBlazor 9.2.0, Semantic Kernel 1.74.0, Markdig 1.1.1.
<Any target-specific constraints>.
```
---
## proposal.md
**Integration Rule**: This feature is additive only. DO NOT modify existing files, components, services, or patterns. If the target already has an equivalent service (HTTP wrapper, markdown renderer, etc.), use the existing one. If a task conflicts with existing code, stop and notify the user before proceeding. Existing applicationX code takes precedence in all cases.
Build a complete AI chat interface on an existing MudBlazor app:
- Chat page with streaming AI responses via SSE
- Backend using Semantic Kernel with tool calling (extraction plugin)
- Multi-turn conversation support
- Rich text rendering (Markdig + HTML sanitization)
- Full xUnit test coverage
Assumes MudBlazor is already installed and configured.
---
## design.md
### Integration: additive only
- This feature is a GUEST in the target application — add new files, never modify existing ones
- If the target already has a service that overlaps (e.g., its own HttpClient wrapper, markdown renderer, error handling), use theirs — do not create a duplicate
- Conform to the target's code style, naming conventions, and DI patterns
- If a task would require modifying existing code, **stop and notify the user** — the user decides whether to skip, adapt, or redesign
- Only touch existing files to add a nav link or a DI registration line — never restructure
### Streaming: SSE over SignalR
- SSE is simpler — one-directional text stream over HTTP, no WebSocket negotiation
- Blazor WASM supports streaming via `SetBrowserResponseStreamingEnabled(true)` + `ResponseHeadersRead`
- Client parses line-by-line: `data: {"text":"..."}\n\n` for deltas, `data: [DONE]\n\n` for end, `data: {"error":"..."}` for errors
- **CRITICAL SSE loop pattern** — use `while ((line = await reader.ReadLineAsync()) != null)`. Do NOT use `reader.EndOfStream` — it does a synchronous peek that Blazor WASM's fetch-backed stream rejects at runtime
### AI orchestration: Semantic Kernel
- SK provides chat completion connectors, plugin system, and auto function calling
- OpenAI connector works with any OpenAI-compatible endpoint (e.g. local proxy) — base URL **must** include `/v1`
- ExtractionPlugin imported per-request via `ImportPluginFromObject` (not at Kernel build, avoids plugin state leaking across requests)
- `FunctionChoiceBehavior.Auto()` lets the LLM autonomously call tools and retry
### Client architecture
- **Typed HttpClient** (`ChatApiClient`) — centralizes API paths, easy to mock for tests, IHttpClientFactory manages handler lifetime
- **MarkdownService** (singleton) — Markdig pipeline is immutable/thread-safe; two-pass sanitization (regex strip script/style, then tag allowlist) rather than a sanitization library to keep dependencies minimal
- **Rendered HTML cache** — `Dictionary<ChatMessage, string>` prevents re-running Markdig on all completed messages during every `StateHasChanged` while streaming
### Chat UI layout — "Sales Assistant" page
- Page renders **inside MudMainContent** — do NOT add a separate AppBar or layout
- Route: `/sales-assistant`, add MudNavLink in existing sidebar NavMenu
- Flexbox column: message-list (flex 1, scrollable) + input-area (pinned bottom)
- MudPaper bubbles: user right-aligned (primary bg), assistant left-aligned (surface bg)
- `::deep` CSS selectors needed for MudBlazor child components and MarkupString-injected HTML
- Auto-scroll via JS interop (`scrollTop = scrollHeight`) — no built-in Blazor scroll API
- **Container height**: `calc(100vh - 64px)` — CRC app uses regular MudAppBar (~64px), not Dense. MudDrawer width is handled by MudLayout automatically — no horizontal calc needed
### Multi-turn
- Full conversation history sent with each request (all messages with non-empty content)
- Empty assistant placeholder excluded to avoid confusing the LLM
### Test strategy
- API: `WebApplicationFactory<Program>` with mocked `IChatCompletionService` — tests SSE contract without hitting real LLM
- Client: mock `HttpMessageHandler` with canned SSE response streams
- Requires `public partial class Program { }` in API for test factory access
---
## tasks.md
### Phase 1: Shared Models
- [ ] Create `ChatMessage.cs` in Shared/Models — Role (string), Content (string), Timestamp (DateTime)
- [ ] Create `ChatRequest.cs` — List<ChatMessage> Messages
- [ ] Create `HealthResponse.cs` — Status (string), Timestamp (DateTime)
- [ ] Create `ExtractedFields.cs` — Client?, Project?, Hours?, Rate?, Currency?, Date? (required); Description?, PoNumber? (optional)
- [ ] Create `ValidationResult.cs` — IsValid (bool), Errors (List<string>)
### Phase 2: API Backend
- [ ] Add NuGet: Microsoft.SemanticKernel 1.74.0, Connectors.OpenAI 1.74.0
- [ ] Create `ExtractionPlugin.cs` in Plugins/ — [KernelFunction] validates extracted fields JSON
- [ ] Configure Program.cs: AddControllers, AddOpenAIChatCompletion (base URL must include /v1), AddKernel, ExtractionPlugin singleton, CORS for client origin
- [ ] Create `HealthController.cs` — GET /api/health returns HealthResponse
- [ ] Create `ChatController.cs` — POST /api/chat, streams via SK GetStreamingChatMessageContentsAsync, SSE format: data: {"text":"..."}\n\n and data: [DONE]\n\n
- [ ] Add `public partial class Program { }` for test accessibility
- [ ] Add appsettings.json: ResponsesApi:BaseUrl, ResponsesApi:Model
### Phase 3: Client Services
- [ ] Add NuGet: Microsoft.Extensions.Http 9.0.4, Markdig 1.1.1
- [ ] Create `ChatApiClient.cs` — typed HttpClient with GetHealthAsync and SendChatStreamingAsync (IAsyncEnumerable<string>). Use SetBrowserResponseStreamingEnabled(true) + ResponseHeadersRead. Parse SSE with ReadLineAsync null check (not EndOfStream).
- [ ] Create `MarkdownService.cs` — Markdig with UseAdvancedExtensions, two-pass HTML sanitization (script/style strip, tag allowlist)
- [ ] Register in Program.cs: AddMudServices, AddSingleton<MarkdownService>, AddHttpClient<ChatApiClient>
- [ ] Add wwwroot/appsettings.json with ApiBaseUrl_Http and ApiBaseUrl_Https
### Phase 4: Chat UI
- [ ] Create `SalesAssistant.razor` at route "/sales-assistant" — message list with MudPaper bubbles, MudTextField with send icon, Enter key handler
- [ ] Add MudNavLink to existing sidebar NavMenu: "Sales Assistant", icon SmartToy, href /sales-assistant
- [ ] Streaming: append tokens to assistant message, StateHasChanged per token, auto-scroll via JS interop
- [ ] Assistant messages: render as (MarkupString) from MarkdownService inside .markdown-body div, with rendered HTML cache
- [ ] User messages: plain MudText
- [ ] Thinking indicator: MudProgressCircular when assistant content empty during streaming
- [ ] New Chat button: clears messages, visible when conversation exists, disabled during streaming
- [ ] Multi-turn: send all non-empty messages with each request
- [ ] Create `SalesAssistant.razor.css` — flex layout with `calc(100vh - 64px)` height (64px = regular AppBar), message bubbles (user right/primary, assistant left/surface), markdown styles (code blocks, tables, blockquotes, headings, links)
### Phase 5: Tests
- [ ] Create xUnit test project for API with WebApplicationFactory, mock IChatCompletionService
- [ ] Test HealthController (200 + valid response), ChatController (SSE streaming, error handling), ExtractionPlugin (validation logic)
- [ ] Create xUnit test project for Client with mock HttpMessageHandler
- [ ] Test ChatApiClient (delta parsing, error events), MarkdownService (rendering + sanitization)
- [ ] Verify: `dotnet test`

View File

@@ -0,0 +1,206 @@
# Feature: AI Chat Agent with Streaming & Rich Text
## Target: Existing MudBlazor app (.NET 9 / Blazor WASM + ASP.NET Core API)
## Includes: basic-chat-interface, wire-responses-api, migrate-to-semantic-kernel, multi-turn-conversations, add-test-coverage, enable-rich-text-display
## Skipped: migrate-claude-md-to-openspec (project scaffolding only)
## Integration Rule
This feature is **additive only**. Existing applicationX code takes precedence over this spec in all cases.
- **DO NOT** modify existing files, components, layouts, services, or patterns in the target
- **DO NOT** replace existing patterns (e.g., if the target uses a different HttpClient pattern, use theirs)
- **DO** add new files, new nav links, new routes, new DI registrations
- **DO** conform to the target's existing code style, naming, and project structure
- If the target already has an equivalent service (markdown renderer, HTTP wrapper, etc.), **use theirs**
- If a task conflicts with existing target code, **stop and notify the user** — do not skip silently; the user decides whether to skip, adapt, or redesign
## Assumes
- .NET 9 solution with Blazor WASM client + ASP.NET Core API projects + Shared class library
- MudBlazor 9.2.0 installed and configured (AddMudServices, providers in MainLayout)
- MudLayout with MudAppBar and MudMainContent in MainLayout.razor
## Target Layout (CRC app)
```
|------ ~220px ------|-------------- fluid ---------------|
| | ≡ CRC 0.0.0 APR-CRC-PROD-... | ← MudAppBar (regular, ~64px)
| Home |------------------------------------|
| Pricer | |
| Market Data | MudMainContent (@Body) |
| XVA Statistics | ← Chat page renders here |
| Sales | |
| Sales Assistant ★ | |
| | |
| MudDrawer (~220px) | |
|---------------------|-----------------------------------|
```
- MudAppBar: **regular height (~64px)**, not Dense — hamburger toggle, title + version, env badge
- MudDrawer: left, **~220px**, toggled by hamburger, contains MudNavMenu
- MudMainContent: fluid width, pages render via @Body
- "Sales Assistant" added as new MudNavLink in existing MudNavMenu
- Chat page renders **inside MudMainContent** — must not set its own AppBar or layout
- Available viewport: `height: calc(100vh - 64px)`, width: fluid minus drawer when open
## Packages
**API project:**
- `Microsoft.SemanticKernel` 1.74.0
- `Microsoft.SemanticKernel.Connectors.OpenAI` 1.74.0
**Client project:**
- `Microsoft.Extensions.Http` 9.0.4
- `Markdig` 1.1.1
**Test projects (xUnit):**
- `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`
- API tests: `Moq`, `Microsoft.AspNetCore.Mvc.Testing`
- Client tests: `Moq`
## Architecture
Three-project solution: WASM client sends chat requests to ASP.NET Core API, which processes them through Semantic Kernel (pointed at an OpenAI-compatible endpoint) and streams token deltas back as SSE. Client renders assistant markdown as sanitized HTML. An extraction plugin demonstrates SK tool calling.
## Shared Models
### `ChatMessage` — Shared/Models/
- `string Role` ("user" | "assistant"), `string Content`, `DateTime Timestamp`
### `ChatRequest` — Shared/Models/
- `List<ChatMessage> Messages`
### `HealthResponse` — Shared/Models/
- `string Status`, `DateTime Timestamp`
### `ExtractedFields` — Shared/Models/
- Required: `string? Client`, `string? Project`, `decimal? Hours`, `decimal? Rate`, `string? Currency`, `string? Date`
- Optional: `string? Description`, `string? PoNumber`
### `ValidationResult` — Shared/Models/
- `bool IsValid`, `List<string> Errors`
## Components
### API: `Program.cs`
- Register `AddControllers()`, `AddOpenAIChatCompletion()` with configurable endpoint, `AddKernel()`, ExtractionPlugin singleton
- CORS policy allowing Blazor client origin, any header/method
- Config keys: `ResponsesApi:BaseUrl` (default `http://localhost:8317/v1`), `ResponsesApi:Model`, `ResponsesApi:ApiKey`
- IMPORTANT: Base URL must include `/v1` — the OpenAI SDK appends `chat/completions` directly
- Add `public partial class Program { }` for test accessibility
### API: `ChatController` — Controllers/
- POST `/api/chat` accepts `ChatRequest`, returns `text/event-stream`
- Get `IChatCompletionService` from Kernel, convert messages to `ChatHistory`
- Import ExtractionPlugin from DI: `_kernel.ImportPluginFromObject(plugin, "Extraction")`
- Use `OpenAIPromptExecutionSettings` with `FunctionChoiceBehavior.Auto()`
- Stream via `GetStreamingChatMessageContentsAsync()`, emit non-empty chunks as SSE
- SSE format: `data: {"text":"<delta>"}\n\n`, completion: `data: [DONE]\n\n`, error: `data: {"error":"<msg>"}\n\n`
- Catch `HttpRequestException` → error SSE event, `TaskCanceledException` → silent (client disconnect)
### API: `HealthController` — Controllers/
- GET `/api/health` → returns `HealthResponse` with status "Healthy" and UTC timestamp
### API: `ExtractionPlugin` — Plugins/
- `[KernelFunction("validate_extracted_fields")]` method accepting JSON string
- Deserialize to `ExtractedFields`, validate required fields non-null, Hours/Rate > 0
- Return `ValidationResult` as JSON string
### Client: `Program.cs`
- Register `AddMudServices()`, `MarkdownService` (singleton)
- Register `AddHttpClient<ChatApiClient>` with API base URL from config
- Config: `ApiBaseUrl_Https`, `ApiBaseUrl_Http` in wwwroot/appsettings.json
- Auto-detect HTTPS from `HostEnvironment.BaseAddress`
### Client: `ChatApiClient` — Services/
- Typed HttpClient wrapper
- `GetHealthAsync()` → GET api/health, returns `HealthResponse?`
- `SendChatStreamingAsync(ChatRequest)``IAsyncEnumerable<string>`
- Build `HttpRequestMessage` manually
- Call `httpRequest.SetBrowserResponseStreamingEnabled(true)` (Blazor WASM extension)
- Send with `HttpCompletionOption.ResponseHeadersRead`
- Parse SSE line-by-line: extract `"text"` field, throw on `"error"` field, stop on `[DONE]`
- SSE loop: see "Critical Patterns" section below — do NOT use `reader.EndOfStream`
### Client: `MarkdownService` — Services/
- Singleton wrapping Markdig with `UseAdvancedExtensions()` pipeline
- `ConvertToHtml(string markdown)` → sanitized HTML string
- Two-pass sanitization:
1. Strip `<script>` and `<style>` blocks with content (regex)
2. Filter tags against allowlist: p, h1-h6, strong, em, code, pre, ul, ol, li, a, table, thead, tbody, tr, th, td, br, blockquote
- Only `href` attribute allowed (on `<a>` only)
### Client: `SalesAssistant.razor` — Pages/, route `/sales-assistant`
- Page title: "Sales Assistant"
- Add `MudNavLink` in existing sidebar NavMenu: icon `Icons.Material.Filled.SmartToy`, href `/sales-assistant`
- Message list with MudPaper bubbles: user (right-aligned, primary bg), assistant (left-aligned, surface bg)
- MudTextField with send icon adornment, Enter key handler, disabled during streaming
- Empty state with centered title text
- "New Chat" button (MudButton) above input, visible when messages exist, disabled during streaming
- Streaming: append tokens to assistant message, call `StateHasChanged()` + auto-scroll per token
- Assistant messages: render via `(MarkupString)GetRenderedHtml(message)` inside `.markdown-body` div
- Rendered HTML cache (`Dictionary<ChatMessage, string>`) — cache on stream completion, render fresh during streaming
- Auto-scroll via JS interop: `document.querySelector('.message-list').scrollTop = .scrollHeight`
- Thinking indicator: `MudProgressCircular` when assistant content is empty and streaming
- Multi-turn: send all messages with non-empty content (excludes empty assistant placeholder)
- User messages: plain `MudText`, no markdown
### Client: `SalesAssistant.razor.css` — scoped styles
- `.chat-container`: flex column, `height: calc(100vh - 64px)`, max-width 800px, centered within MudMainContent
- `.message-list`: flex 1, overflow-y auto, flex column, gap 0.75rem
- Message alignment: `.message-user` flex-end, `.message-assistant` flex-start
- `::deep .bubble-user`: primary color bg, white text, rounded except bottom-right
- `::deep .bubble-assistant`: surface bg, border, rounded except bottom-left
- `::deep .markdown-body` styles: code blocks (gray bg, monospace), tables (bordered), blockquotes (left border primary), headings (scaled down), links (primary color, underline)
## Wiring (dependency order)
1. **Shared project**: Create all models (ChatMessage, ChatRequest, HealthResponse, ExtractedFields, ValidationResult)
2. **API Program.cs**: AddControllers → AddOpenAIChatCompletion → AddKernel → AddSingleton<ExtractionPlugin> → AddCors → Build → UseCors → UseAuthorization → MapControllers
3. **Client Program.cs**: AddMudServices → AddSingleton<MarkdownService> → AddHttpClient<ChatApiClient> → Build → RunAsync
4. **API appsettings.json**: `{"ResponsesApi": {"BaseUrl": "http://localhost:8317/v1", "Model": "claude-sonnet-4-6"}}`
5. **Client wwwroot/appsettings.json**: `{"ApiBaseUrl_Http": "http://localhost:7000", "ApiBaseUrl_Https": "https://localhost:7100"}`
## Critical Patterns (copy these, do not improvise)
### SSE read loop in Blazor WASM
```csharp
// DO NOT use reader.EndOfStream — it does a synchronous peek,
// which Blazor WASM's fetch-backed stream rejects at runtime.
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (!line.StartsWith("data: ")) continue;
var data = line.Substring(6);
if (data == "[DONE]") yield break;
// parse {"text":"..."} or {"error":"..."}
}
```
### Chat container height
```css
/* 64px = MudAppBar regular height in the CRC app.
The chat page renders inside MudMainContent which already
sits below the AppBar, so use 100% of the parent height
rather than calculating from viewport. If MudMainContent
does not have height set, fall back to calc(100vh - 64px). */
.chat-container {
height: calc(100vh - 64px);
/* MudDrawer width is handled by MudLayout — the chat container
is fluid within MudMainContent, no horizontal calc needed. */
}
```
## Behavior (non-obvious)
- Base URL **must** include `/v1` — SK's OpenAI connector appends `chat/completions` directly
- WASM streaming requires **both** `SetBrowserResponseStreamingEnabled(true)` and `ResponseHeadersRead`
- Only emit SSE chunks where `!string.IsNullOrEmpty(chunk.Content)` — tool call chunks have no text
- Cache rendered HTML for completed messages to avoid re-running Markdig on every `StateHasChanged`
- `public partial class Program { }` at bottom of API Program.cs for `WebApplicationFactory<Program>` in tests
- ExtractionPlugin is imported per-request via `ImportPluginFromObject` (not at Kernel build time)
- Chat sends all messages with non-empty content — filters out the empty assistant placeholder
- Chat container height assumes MudAppBar Dense (48px) — if your AppBar height differs, adjust the calc() or use a CSS variable
- On mobile viewports, `100vh` includes the browser address bar — use `100dvh` if targeting mobile
## Test Coverage
- **HealthControllerTests**: GET /api/health returns 200 with valid HealthResponse
- **ChatControllerTests**: Mock `IChatCompletionService`, verify SSE text deltas + [DONE]; verify error handling; use `WebApplicationFactory<Program>`
- **ExtractionPluginTests**: Valid fields → IsValid; missing required → errors; invalid JSON → error; zero hours → error
- **ChatApiClientTests**: Mock `HttpMessageHandler` with canned SSE streams; verify delta parsing order; verify error event throws
- **MarkdownServiceTests**: Verify rendering (bold, italic, code, lists, tables, links, headings); verify sanitization (script/style stripping, event handler removal, tag allowlist)

View 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
```

View 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)

View 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.02.0
public double? TopP { get; set; } // 0.01.0
public int? MaxTokens { get; set; } // 14096
}
// 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)

View File

@@ -20,27 +20,37 @@ The agent SHALL extract a predefined set of key-value pairs from user-provided n
### Requirement: Predefined extraction schema
The system SHALL define a fixed set of known field names and types as a strongly-typed C# class. All extraction output MUST conform to this schema.
The system SHALL define the extraction schema as a `TradeItem` class with fields: valuedate, counterparty, legal_entity, trade_id, display_ccy, pv, breakclause. Extraction output SHALL be wrapped in an `ExtractionResult` containing a `List<TradeItem>`. All extraction output MUST conform to this schema.
#### Scenario: Output conforms to schema
- **WHEN** the agent produces extracted fields
- **THEN** every key in the output matches a field defined in the schema and values match expected types
- **WHEN** the agent produces extracted fields from an email
- **THEN** every item in the output is a valid TradeItem with all required fields matching expected types
#### Scenario: Multiple items from one email
- **WHEN** the agent extracts data from an email containing multiple trade legs
- **THEN** the output ExtractionResult contains one TradeItem per trade leg
### Requirement: Autonomous validation via tool calling
The agent SHALL validate extracted fields by calling a validation tool function. The validation tool checks that all required fields are present and correctly typed.
The agent SHALL validate extracted fields by calling external API tools exposed as Semantic Kernel functions. Validation tools include counterparty lookup, trade validation, currency validation, and schema validation. Each tool returns structured results that the agent reasons about.
#### Scenario: Validation passes
- **WHEN** the agent calls the validation tool with a complete and correct extraction
- **WHEN** the agent calls the schema validation tool with a complete and correct ExtractionResult
- **THEN** the tool returns a success result and the agent returns the final output to the user
#### Scenario: Validation fails with fixable errors
- **WHEN** the validation tool returns errors for missing or malformed fields
- **WHEN** a validation tool returns errors for missing or malformed fields
- **THEN** the agent re-reads the source text and attempts to fix the extraction without user intervention
#### Scenario: Counterparty disambiguation required
- **WHEN** the counterparty lookup tool returns multiple candidate (counterparty, legal_entity) tuples
- **THEN** the agent presents the candidates to the user as a numbered list in the chat and waits for the user to select one before completing the extraction
### Requirement: Autonomous retry with iteration cap
The agent SHALL retry extraction autonomously up to 3 times when validation fails. After exhausting retries, the agent MUST escalate to the user.
@@ -57,13 +67,18 @@ The agent SHALL retry extraction autonomously up to 3 times when validation fail
### Requirement: Human-in-the-loop clarification
When the agent escalates to the user, the user SHALL be able to provide the missing information in natural language, and the agent SHALL incorporate the clarification and re-attempt extraction.
When the agent escalates to the user, the user SHALL be able to provide the missing information in natural language, and the agent SHALL incorporate the clarification and re-attempt extraction. Disambiguation of counterparty/legal_entity tuples is a specific case of human-in-the-loop clarification.
#### Scenario: User provides clarification
- **WHEN** the agent asks for clarification about missing fields and the user responds
- **THEN** the agent incorporates the user's response into the conversation context and produces an updated extraction
#### Scenario: User selects counterparty from candidates
- **WHEN** the agent presents a numbered list of counterparty/legal_entity candidates and the user replies with a selection
- **THEN** the agent populates the `legal_entity` field on all relevant TradeItems and proceeds with validation
#### Scenario: Clarification via normal chat
- **WHEN** the agent escalates for clarification

View File

@@ -6,12 +6,27 @@ Define the streaming AI response pipeline — backend chat endpoint using Semant
### Requirement: Chat endpoint proxies to Responses API
The API backend SHALL expose `POST /api/chat` that accepts a list of messages and processes them using a Semantic Kernel chat completion service. The kernel is configured with an OpenAI connector pointed at the existing CLIProxyAPI proxy.
The API backend SHALL expose `POST /api/chat` that accepts a `ChatRequest` containing messages, an optional system prompt, and optional model settings. The request is processed using a Semantic Kernel chat completion service. When a system prompt is provided, it SHALL be added as the first system message in the ChatHistory. When model settings are provided, non-null values SHALL be applied to the execution settings. A separate `POST /api/chat/extract` endpoint SHALL handle extraction-specific requests with few-shot prompting.
#### Scenario: Successful chat request
#### Scenario: Successful chat request with system prompt
- **WHEN** the client sends a POST to `/api/chat` with a message list
- **THEN** the API processes the messages through the Semantic Kernel and returns the response
- **WHEN** the client sends a POST to `/api/chat` with messages and a system prompt
- **THEN** the API creates a ChatHistory with the system prompt as the first message, followed by the conversation messages, and processes them through Semantic Kernel
#### Scenario: Successful chat request with model settings
- **WHEN** the client sends a POST to `/api/chat` with messages and model settings (e.g., Temperature=0.3)
- **THEN** the API applies the settings to OpenAIPromptExecutionSettings before calling the Semantic Kernel
#### Scenario: Successful chat request without optional fields
- **WHEN** the client sends a POST to `/api/chat` with only messages (no system prompt, no settings)
- **THEN** the API processes the request with default behavior (no system message, default execution settings)
#### Scenario: Extraction request routed to dedicated endpoint
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML
- **THEN** the API uses the few-shot ChatHistory prefix and extraction tools instead of the general chat configuration
### Requirement: Streaming response delivery

View File

@@ -25,7 +25,7 @@ The chat page SHALL display messages in a vertically scrolling list, with each m
### Requirement: Message input
The chat page SHALL provide a text input area at the bottom of the page where the user can type and submit messages.
The chat page SHALL provide a text input area at the bottom of the page where the user can type and submit messages. The input area SHALL also include a file upload button for triggering email extraction.
#### Scenario: Submit via button
@@ -45,7 +45,12 @@ The chat page SHALL provide a text input area at the bottom of the page where th
#### Scenario: Input disabled during streaming
- **WHEN** the assistant is currently streaming a response
- **THEN** the input field and send button are disabled until streaming completes
- **THEN** the input field, send button, and upload button are disabled until streaming completes
#### Scenario: Upload button opens file picker
- **WHEN** the user clicks the upload button in the input area
- **THEN** a file picker dialog opens filtered to .html files
### Requirement: Thinking indicator
@@ -100,9 +105,24 @@ The message list SHALL automatically scroll to the newest message when a new mes
### Requirement: Chat page is default route
The chat page SHALL be the default route (`/`) of the application.
The chat page SHALL be routed at `/sales-assistant` (or `/` with redirect). The page content SHALL be wrapped in a MudTabs container with the conversation UI in the first tab panel.
#### Scenario: App opens to chat
#### Scenario: App opens to chat via redirect
- **WHEN** the user navigates to the root URL
- **WHEN** the user navigates to the root URL `/`
- **THEN** the browser redirects to `/sales-assistant` and the chat page is displayed
#### Scenario: Direct navigation to sales-assistant
- **WHEN** the user navigates to `/sales-assistant`
- **THEN** the chat page is displayed
#### Scenario: Page loads with chat tab active
- **WHEN** the user navigates to the chat page
- **THEN** the Chat tab is active showing the message list and input area
#### Scenario: Chat functionality unchanged
- **WHEN** the user sends a message from the Chat tab
- **THEN** the assistant response streams in exactly as before, with the same SSE contract and rendering behavior

View File

@@ -0,0 +1,42 @@
## Purpose
Define the email upload UX — drag-and-drop, file picker, and upload behavior constraints during streaming.
## Requirements
### Requirement: Drag-and-drop email upload
The chat message area SHALL accept files dragged from the desktop or file explorer. When a supported file is dropped, the client SHALL read the file content and send it to the extraction endpoint.
#### Scenario: Drag HTML file onto chat
- **WHEN** the user drags an .html file over the message area
- **THEN** a visual drop indicator appears (e.g., highlighted border, overlay text "Drop email here")
#### Scenario: Drop HTML file triggers extraction
- **WHEN** the user drops an .html file onto the message area
- **THEN** the client reads the HTML content, sends it to `POST /api/chat/extract`, and streams the extraction response in the chat
#### Scenario: Unsupported file type rejected
- **WHEN** the user drops a non-.html file (e.g., .pdf, .docx)
- **THEN** the client shows a brief error message indicating only .html files are supported
### Requirement: File picker upload button
The chat input area SHALL include an upload button (e.g., attachment icon) that opens a file picker dialog for selecting .html email files.
#### Scenario: Upload via file picker
- **WHEN** the user clicks the upload button and selects an .html file
- **THEN** the client reads the HTML content and sends it to the extraction endpoint, same as drag-and-drop
### Requirement: Upload disabled during streaming
The upload zone and file picker SHALL be disabled while a response is streaming.
#### Scenario: Drop during streaming
- **WHEN** the user attempts to drop a file while the assistant is streaming
- **THEN** the drop is ignored and no extraction request is sent

View File

@@ -0,0 +1,51 @@
## Purpose
Define the extraction conversation flow — mode tracking, visual indicators, follow-up message routing, and upload message display.
## Requirements
### Requirement: Extraction mode tracking
The chat page SHALL track whether the current conversation is in extraction mode. Extraction mode is entered when an email is uploaded and exited when the user starts a new chat.
#### Scenario: Enter extraction mode on upload
- **WHEN** the user uploads an email file
- **THEN** the conversation enters extraction mode and subsequent messages are routed to the extraction endpoint
#### Scenario: Exit extraction mode on New Chat
- **WHEN** the user clicks "New Chat" while in extraction mode
- **THEN** the conversation exits extraction mode and returns to general chat routing
### Requirement: Extraction mode visual indicator
The chat page SHALL display a visual indicator when in extraction mode so the user knows their messages are part of an extraction conversation.
#### Scenario: Indicator shown in extraction mode
- **WHEN** the conversation is in extraction mode
- **THEN** a visual indicator (e.g., chip, banner, or subtitle) is visible showing the extraction context
#### Scenario: Indicator hidden in general mode
- **WHEN** the conversation is in general chat mode
- **THEN** no extraction indicator is shown
### Requirement: Follow-up messages route to extraction endpoint
In extraction mode, text messages typed by the user SHALL be sent to the extraction endpoint with the original email HTML and full conversation history, not to the general chat endpoint.
#### Scenario: User replies to disambiguation question
- **WHEN** the agent asks "Which legal entity?" and the user types "1"
- **THEN** the client sends an ExtractionRequest with the original email HTML plus all messages (assistant question + user reply) to `POST /api/chat/extract`
### Requirement: Email upload message in chat
When an email is uploaded, the chat SHALL display a user message indicating the upload (e.g., showing the filename) before the extraction response streams in.
#### Scenario: Upload message displayed
- **WHEN** the user drops "trade_request.html"
- **THEN** a user message appears in the chat like "[Uploaded: trade_request.html]" followed by the streaming extraction response

View File

@@ -0,0 +1,47 @@
## Purpose
Define the extraction-specific API endpoint — request/response contract, few-shot ChatHistory integration, and tool isolation from the general chat endpoint.
## Requirements
### Requirement: Extraction API endpoint
The API SHALL expose `POST /api/chat/extract` that accepts an `ExtractionRequest` containing the email HTML content and optional follow-up conversation messages. The endpoint SHALL use the few-shot ChatHistory prefix (not the user-editable system prompt) and load extraction-specific SK plugins.
#### Scenario: Initial extraction request
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML and no follow-up messages
- **THEN** the API assembles the few-shot ChatHistory, appends the email as the final user message, and streams the extraction response via SSE
#### Scenario: Follow-up disambiguation request
- **WHEN** the client sends a POST to `/api/chat/extract` with email HTML and follow-up messages (e.g., user selecting a counterparty)
- **THEN** the API assembles the few-shot ChatHistory, appends the email, appends all follow-up messages, and streams the continuation response via SSE
#### Scenario: SSE streaming contract
- **WHEN** the extraction endpoint streams a response
- **THEN** it uses the same SSE format as `/api/chat`: `data: {"text":"..."}\n\n` for deltas and `data: [DONE]\n\n` for completion
### Requirement: ExtractionRequest DTO
The system SHALL define an `ExtractionRequest` class with `EmailHtml` (string, required) and `Messages` (List<ChatMessage>, optional) for follow-up conversation context.
#### Scenario: First request has email only
- **WHEN** the user uploads an email for the first time
- **THEN** the ExtractionRequest contains `EmailHtml` with the email content and an empty `Messages` list
#### Scenario: Follow-up request includes conversation
- **WHEN** the user replies to a disambiguation question
- **THEN** the ExtractionRequest contains the original `EmailHtml` plus `Messages` with the full assistant/user exchange since the extraction started
### Requirement: Extraction endpoint uses extraction tools only
The extraction endpoint SHALL import only the extraction-specific SK plugins (counterparty lookup, trade validation, currency validation, schema validation). General chat tools (if any) SHALL NOT be loaded for extraction requests.
#### Scenario: Tool isolation
- **WHEN** the extraction endpoint processes a request
- **THEN** only extraction-related KernelFunctions are available to the LLM

View File

@@ -0,0 +1,67 @@
## Purpose
Define the TradeItem extraction schema, ExtractionResult wrapper, and mapping rules for converting sales email content into structured trade data.
## Requirements
### Requirement: TradeItem schema
The system SHALL define a `TradeItem` class with the following fields representing a single trade leg extracted from a sales email:
- `valuedate` (string, dd/MM/yyyy format)
- `counterparty` (string, full legal name as it appears in the email)
- `legal_entity` (string, nullable — populated after counterparty disambiguation via lookup tool)
- `trade_id` (long, Murex trade identifier)
- `display_ccy` (string, ISO currency code e.g. "GBP", "USD")
- `pv` (double, present value)
- `breakclause` (string, "Y" or "N")
JSON serialization SHALL use snake_case property names via `[JsonPropertyName]` attributes.
#### Scenario: All fields populated
- **WHEN** the extraction agent produces a TradeItem with all fields
- **THEN** the JSON output contains all seven fields with snake_case keys and correct types
#### Scenario: Legal entity null before disambiguation
- **WHEN** the extraction agent produces a TradeItem before counterparty lookup
- **THEN** the `legal_entity` field is null and all other fields are populated
### Requirement: ExtractionResult wrapper
The system SHALL define an `ExtractionResult` class containing a `List<TradeItem> Items` property. All extraction output from a single email SHALL be wrapped in this object.
#### Scenario: Single email with multiple trade legs
- **WHEN** an email contains two swaps with two legs each (4 trades total)
- **THEN** the ExtractionResult contains an `items` array with 4 TradeItem objects
#### Scenario: JSON output structure
- **WHEN** the ExtractionResult is serialized to JSON
- **THEN** the output has the shape `{"items": [{"valuedate": "...", ...}, ...]}`
### Requirement: Extraction mapping rules
The extraction agent SHALL follow these mapping rules when converting email content to TradeItems:
- Each swap leg (identified by a unique Murex trade ID) becomes a separate TradeItem
- The `valuedate` SHALL be parsed from date references in the email (e.g., "OB 27/11/2025") and formatted as dd/MM/yyyy
- The `counterparty` SHALL be the full legal entity name as stated in the email prose
- The `display_ccy` SHALL be derived from the currency symbol or code in the email (e.g., "£" or "PV (£)" → "GBP")
- The `breakclause` SHALL default to "N" if not explicitly mentioned in the email
- The `pv` SHALL be the numeric present value without formatting (no commas, no currency symbols)
#### Scenario: Flatten multi-leg swap into individual items
- **WHEN** the email contains a swap with Coupon Leg (Murex 79353083) and APD leg (Murex 79353084)
- **THEN** the output contains two separate TradeItems, one per Murex ID
#### Scenario: Currency symbol to ISO code mapping
- **WHEN** the email shows PV values in "PV (£)" column
- **THEN** the `display_ccy` field is set to "GBP"
#### Scenario: Default breakclause
- **WHEN** the email does not mention break clauses
- **THEN** all TradeItems have `breakclause` set to "N"

View File

@@ -0,0 +1,84 @@
## Purpose
Define the Semantic Kernel tool functions for extraction validation — counterparty lookup, trade validation, currency validation, schema validation, external API configuration, and error handling.
## Requirements
### Requirement: Counterparty lookup tool
The extraction plugin SHALL expose a `lookup_counterparty` Semantic Kernel function that accepts a counterparty name string and calls the external counterparty API. The tool SHALL return a list of candidate (counterparty, legal_entity) tuples.
#### Scenario: Single match found
- **WHEN** the tool is called with a counterparty name that matches exactly one record
- **THEN** the tool returns a single candidate with the counterparty name and legal entity ID
#### Scenario: Multiple matches found (disambiguation needed)
- **WHEN** the tool is called with a counterparty name that matches multiple records
- **THEN** the tool returns all matching candidates so the agent can present them to the user for selection
#### Scenario: No match found
- **WHEN** the tool is called with a counterparty name that matches no records
- **THEN** the tool returns an empty list and an informative message so the agent can ask the user for clarification
### Requirement: Trade validation tool
The extraction plugin SHALL expose a `validate_trade` Semantic Kernel function that accepts a trade ID and calls the external trade validation API to verify the trade exists.
#### Scenario: Valid trade ID
- **WHEN** the tool is called with a known trade ID
- **THEN** the tool returns a success result confirming the trade exists
#### Scenario: Invalid trade ID
- **WHEN** the tool is called with an unknown trade ID
- **THEN** the tool returns an error result so the agent can flag it to the user
### Requirement: Currency validation tool
The extraction plugin SHALL expose a `validate_currency` Semantic Kernel function that accepts a currency code and calls the external currency validation API to verify it is a valid ISO currency code.
#### Scenario: Valid currency code
- **WHEN** the tool is called with "GBP"
- **THEN** the tool returns a success result
#### Scenario: Invalid currency code
- **WHEN** the tool is called with an unrecognized code
- **THEN** the tool returns an error with suggestions for valid codes
### Requirement: Schema validation tool
The extraction plugin SHALL expose a `validate_schema` Semantic Kernel function that accepts the full ExtractionResult JSON and validates that all required fields are present and correctly typed for every TradeItem.
#### Scenario: Valid extraction result
- **WHEN** the tool is called with a complete and correctly typed ExtractionResult JSON
- **THEN** the tool returns a success result with no errors
#### Scenario: Missing required fields
- **WHEN** the tool is called with a TradeItem missing the `trade_id` field
- **THEN** the tool returns a failure result listing the missing fields and which item they belong to
### Requirement: External API configuration
All external API base URLs SHALL be configurable via `appsettings.json` under an `ExternalApis` section. Each tool's HttpClient SHALL read its base URL from configuration at startup.
#### Scenario: Configuration at startup
- **WHEN** the API starts
- **THEN** it reads external API base URLs from the `ExternalApis` configuration section and configures typed HttpClients accordingly
### Requirement: External API error handling
Each tool SHALL handle HTTP errors from external APIs gracefully, returning a structured error message that the LLM agent can reason about rather than throwing exceptions.
#### Scenario: External API unavailable
- **WHEN** a tool calls an external API that is unreachable
- **THEN** the tool returns an error result with a descriptive message (e.g., "Counterparty API unavailable") so the agent can inform the user

View File

@@ -0,0 +1,56 @@
## Purpose
Define the few-shot prompting infrastructure for extraction — example folder structure, instruction template, ChatHistory assembly, and evaluation folder.
## Requirements
### Requirement: Few-shot example folder structure
The system SHALL store few-shot examples at `examples/extraction/few-shot/` with numbered subdirectories (e.g., `01/`, `02/`). Each subdirectory SHALL contain `input.html` (the example email) and `output.json` (the expected ExtractionResult JSON).
#### Scenario: Example folder layout
- **WHEN** the application starts
- **THEN** it reads example pairs from `examples/extraction/few-shot/` in numeric directory order
#### Scenario: Adding a new example
- **WHEN** a new subdirectory (e.g., `04/`) is added with `input.html` and `output.json`
- **THEN** the new example is included in the few-shot ChatHistory prefix after the next application restart
### Requirement: Extraction instruction template
The system SHALL load a fixed instruction template from `examples/extraction/instruction-template.txt` that defines the extraction task, the TradeItem schema, and the mapping rules (date parsing, leg flattening, currency mapping, breakclause defaults). This template is NOT the user-editable system prompt.
#### Scenario: Template loaded at startup
- **WHEN** the application starts
- **THEN** the instruction template is loaded from disk and used as the system message in the extraction ChatHistory
#### Scenario: Template content
- **WHEN** the instruction template is loaded
- **THEN** it contains the TradeItem field definitions, expected JSON output format, and explicit mapping rules
### Requirement: ChatHistory assembly with few-shot examples
The system SHALL provide a `FewShotService` that assembles a reusable ChatHistory prefix at startup: the instruction template as a system message, followed by alternating User (input.html) and Assistant (output.json) messages for each example. Each extraction request SHALL clone this prefix and append the real email as the final user message.
#### Scenario: ChatHistory prefix structure
- **WHEN** the service assembles the prefix with 3 examples
- **THEN** the ChatHistory contains: 1 system message + 3 user messages + 3 assistant messages (7 messages total)
#### Scenario: Prefix cached and cloned per request
- **WHEN** an extraction request arrives
- **THEN** the service clones the cached prefix (not re-reading from disk) and appends the email content as a new user message
### Requirement: Evaluation example folder
The system SHALL support an `examples/extraction/evaluation/` folder for bulk examples used in offline testing. This folder is NOT loaded at startup and NOT used in the few-shot prompt.
#### Scenario: Evaluation folder ignored at runtime
- **WHEN** the application starts
- **THEN** it does not load examples from `examples/extraction/evaluation/`

View File

@@ -0,0 +1,47 @@
## Purpose
Define the shared data models and API contract for system prompt and model settings — ModelSettings class, ChatRequest extensions, and backend handling.
## Requirements
### Requirement: ModelSettings shared model
The Shared project SHALL define a `ModelSettings` class with nullable properties: `Temperature` (double?), `TopP` (double?), `MaxTokens` (int?). Null values indicate "use server default".
#### Scenario: All fields null
- **WHEN** a ModelSettings instance has all null fields
- **THEN** the backend uses Semantic Kernel default values for all parameters
#### Scenario: Partial override
- **WHEN** a ModelSettings instance has Temperature set but TopP and MaxTokens null
- **THEN** only Temperature is overridden; other parameters use defaults
### Requirement: System prompt in chat request
The `ChatRequest` SHALL accept an optional `SystemPrompt` (string?) property. When present and non-empty, the backend SHALL insert it as the first system message in the ChatHistory before user/assistant messages.
#### Scenario: System prompt provided
- **WHEN** a ChatRequest includes a non-empty SystemPrompt
- **THEN** the ChatHistory starts with a system message containing that text, followed by the conversation messages
#### Scenario: System prompt absent
- **WHEN** a ChatRequest has a null or empty SystemPrompt
- **THEN** the ChatHistory contains only user and assistant messages (no system message)
### Requirement: Model settings in chat request
The `ChatRequest` SHALL accept an optional `Settings` (ModelSettings?) property. When present, the backend SHALL apply non-null values to `OpenAIPromptExecutionSettings` before calling the Semantic Kernel.
#### Scenario: Temperature override
- **WHEN** a ChatRequest includes Settings with Temperature = 0.5
- **THEN** the OpenAIPromptExecutionSettings.Temperature is set to 0.5
#### Scenario: No settings provided
- **WHEN** a ChatRequest has null Settings
- **THEN** the backend uses default OpenAIPromptExecutionSettings (only FunctionChoiceBehavior.Auto is set)

View File

@@ -0,0 +1,57 @@
## Purpose
Define the UI controls for configuring system prompt and model parameters — tabbed layout, prompt editor, and settings controls.
## Requirements
### Requirement: System prompt editor tab
The chat page SHALL include a "System Prompt" tab with a multi-line text area where the user can enter a system prompt. The system prompt value SHALL persist across tab switches within the same session.
#### Scenario: User enters a system prompt
- **WHEN** the user navigates to the System Prompt tab and types text
- **THEN** the text is stored in the component state and included in the next chat request
#### Scenario: System prompt survives tab switch
- **WHEN** the user enters a system prompt, switches to the Chat tab, then switches back
- **THEN** the system prompt text is unchanged
### Requirement: Model settings tab
The chat page SHALL include a "Model Settings" tab with controls for Temperature, TopP, and MaxTokens. Each control SHALL display its current value and allow adjustment within valid ranges.
#### Scenario: Temperature control
- **WHEN** the user adjusts the Temperature control
- **THEN** the value is constrained to 0.02.0 and included in the next chat request's settings
#### Scenario: TopP control
- **WHEN** the user adjusts the TopP control
- **THEN** the value is constrained to 0.01.0 and included in the next chat request's settings
#### Scenario: MaxTokens control
- **WHEN** the user sets the MaxTokens value
- **THEN** the value is constrained to 14096 and included in the next chat request's settings
#### Scenario: Default values
- **WHEN** the user has not changed any model settings
- **THEN** the controls show default values (Temperature: 1.0, TopP: 1.0, MaxTokens: empty/unset) and no overrides are sent to the API
### Requirement: Tabbed page layout
The chat page SHALL use MudTabs with three tab panels: "Chat" (the existing conversation UI), "System Prompt" (the prompt editor), and "Model Settings" (the parameter controls).
#### Scenario: Chat tab is default
- **WHEN** the page loads
- **THEN** the Chat tab is active and the conversation UI is displayed
#### Scenario: Tab switching
- **WHEN** the user clicks a different tab
- **THEN** the corresponding panel is displayed and the previous panel is hidden but retains its state

View File

@@ -0,0 +1,42 @@
## Purpose
Define the collapsible sidebar drawer, its hamburger toggle, and the navigation menu with links to application pages.
## Requirements
### Requirement: Collapsible sidebar drawer
The application SHALL have a MudDrawer in MainLayout that contains a navigation menu. The drawer SHALL be toggleable via a hamburger icon button in the AppBar.
#### Scenario: Drawer visible on load
- **WHEN** the application loads
- **THEN** the sidebar drawer is displayed in its default open state with navigation links visible
#### Scenario: Drawer toggles on hamburger click
- **WHEN** the user clicks the hamburger icon in the AppBar
- **THEN** the drawer toggles between open and collapsed states
### Requirement: Navigation menu with Sales Assistant link
The sidebar drawer SHALL contain a MudNavMenu with a "Sales Assistant" navigation link that routes to `/sales-assistant`.
#### Scenario: Sales Assistant link present
- **WHEN** the drawer is open
- **THEN** a "Sales Assistant" link with a SmartToy icon is visible in the navigation menu
#### Scenario: Clicking Sales Assistant navigates to chat
- **WHEN** the user clicks the "Sales Assistant" link
- **THEN** the browser navigates to `/sales-assistant` and the chat page renders in MudMainContent
### Requirement: NavMenu is a separate component
The navigation menu SHALL be implemented as a separate `NavMenu.razor` component in the Layout folder, referenced from MainLayout.
#### Scenario: NavMenu renders inside drawer
- **WHEN** MainLayout renders
- **THEN** the NavMenu component renders inside the MudDrawer with its navigation links

View File

@@ -13,6 +13,7 @@
using System.Text.Json;
using ChatAgent.Api.Plugins;
using ChatAgent.Api.Services;
using ChatAgent.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel;
@@ -67,6 +68,15 @@ namespace ChatAgent.Api.Controllers
// to the messages array in OpenAI's API format. We convert our ChatMessage
// DTOs into SK's format.
var chatHistory = new ChatHistory();
// If a system prompt is provided, add it as the first message in the history.
// The system message sets the AI's persona, behavior, and constraints.
// It must come before any user/assistant messages.
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
{
chatHistory.AddSystemMessage(request.SystemPrompt);
}
foreach (var msg in request.Messages)
{
if (msg.Role == "user")
@@ -93,11 +103,24 @@ namespace ChatAgent.Api.Controllers
// autonomously. SK's built-in safeguard limits the number of auto-invoke
// attempts to prevent runaway loops. If the agent exhausts retries,
// it responds with a clarification request to the user.
// Build execution settings. Start with auto tool calling, then apply
// any client-provided model parameter overrides (temperature, top-p, max tokens).
// Null fields in Settings are ignored — only explicit values override defaults.
var executionSettings = new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
if (request.Settings is not null)
{
if (request.Settings.Temperature.HasValue)
executionSettings.Temperature = request.Settings.Temperature.Value;
if (request.Settings.TopP.HasValue)
executionSettings.TopP = request.Settings.TopP.Value;
if (request.Settings.MaxTokens.HasValue)
executionSettings.MaxTokens = request.Settings.MaxTokens.Value;
}
// GetStreamingChatMessageContentsAsync returns an IAsyncEnumerable that yields
// content chunks as they arrive from the LLM. Each chunk may contain:
// - Text content (the actual response tokens)
@@ -135,6 +158,75 @@ namespace ChatAgent.Api.Controllers
}
}
/// <summary>
/// POST /api/chat/extract -- Accepts an ExtractionRequest with email HTML content,
/// processes it through the extraction agent with few-shot prompting and extraction
/// tools, and streams the response back as SSE events.
///
/// The extraction agent uses a pre-built ChatHistory containing:
/// 1. The instruction template (system message)
/// 2. Few-shot examples (alternating user/assistant turns)
/// 3. The real email (final user message)
///
/// Only extraction-specific tools are loaded (counterparty lookup, trade/currency
/// validation, schema validation). The user-editable system prompt is NOT used here.
/// </summary>
[HttpPost("extract")]
public async Task Extract([FromBody] ExtractionRequest request)
{
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
try
{
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
// Build the ChatHistory from the FewShotService.
// On the first request, only EmailHtml is present.
// On follow-ups (disambiguation), Messages contains the conversation so far.
var fewShotService = HttpContext.RequestServices.GetRequiredService<FewShotService>();
var chatHistory = request.Messages.Count > 0
? fewShotService.CloneWithEmailAndMessages(request.EmailHtml, request.Messages)
: fewShotService.CloneWithEmail(request.EmailHtml);
// Import extraction-specific plugins only.
// General chat tools are NOT loaded for extraction requests.
var extractionPlugin = HttpContext.RequestServices.GetRequiredService<ExtractionPlugin>();
_kernel.ImportPluginFromObject(extractionPlugin, "Extraction");
// Enable auto tool calling so the agent can invoke validation/lookup tools
var executionSettings = new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
// Stream the response using the same SSE format as /api/chat
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(
chatHistory,
executionSettings,
_kernel,
HttpContext.RequestAborted))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
await WriteSSEAsync($"{{\"text\":{JsonSerializer.Serialize(chunk.Content)}}}");
await Response.Body.FlushAsync();
}
}
await WriteSSEAsync("[DONE]");
}
catch (HttpRequestException ex)
{
await WriteSSEAsync($"{{\"error\":{JsonSerializer.Serialize($"Failed to reach LLM service: {ex.Message}")}}}");
await WriteSSEAsync("[DONE]");
}
catch (TaskCanceledException)
{
// Client disconnected — nothing to do
}
}
/// <summary>
/// Writes a single SSE event to the response stream.
/// SSE format: "data: {payload}\n\n"

View File

@@ -1,67 +1,179 @@
// ExtractionPlugin.cs -- Semantic Kernel plugin for validating extracted fields.
// ExtractionPlugin.cs -- Semantic Kernel plugin for CVA trade extraction validation.
//
// In Semantic Kernel, a "plugin" is a class whose methods are exposed to the LLM
// as callable tools (functions). The LLM can decide to invoke these functions during
// a conversation when it determines they are relevant to the task.
// This plugin exposes multiple [KernelFunction] methods as tools the LLM can call
// during an extraction conversation. Each tool either wraps an external API call
// (counterparty lookup, trade validation, currency validation) or performs local
// schema validation.
//
// Key SK concepts demonstrated here:
//
// [KernelFunction] -- Marks a method as a function the LLM can call. SK discovers
// these at startup and includes them in the tool list sent with each LLM request.
//
// [Description] -- Tells the LLM what the function does. The LLM reads this text
// to decide whether and when to call the function. Good descriptions are critical
// for reliable tool use.
//
// Auto-invocation -- When configured with FunctionChoiceBehavior.Auto(), SK
// automatically executes tool calls the LLM makes and feeds the results back,
// allowing the LLM to reason about the output and decide next steps (retry, fix,
// or respond to the user). This creates the agentic loop.
// Key concepts:
// - [KernelFunction] marks methods as callable tools for the LLM
// - [Description] tells the LLM what each tool does and when to use it
// - FunctionChoiceBehavior.Auto() in the controller allows the LLM to invoke these
// tools automatically and reason about results
// - External API errors are caught and returned as structured JSON so the LLM
// can report issues to the user rather than crashing
using System.ComponentModel;
using System.Text.Json;
using ChatAgent.Api.Services;
using ChatAgent.Shared.Models;
using Microsoft.SemanticKernel;
namespace ChatAgent.Api.Plugins
{
/// <summary>
/// Plugin that validates extracted key-value fields against the predefined schema.
/// The LLM calls this after extracting fields from natural language to check
/// whether all required fields are present and correctly typed.
/// Plugin that provides extraction validation and lookup tools.
/// The LLM calls these during the extraction workflow to validate data
/// and look up reference information from external APIs.
/// </summary>
public class ExtractionPlugin
{
// The required fields that must be non-null and non-empty for validation to pass.
// These match the required properties on ExtractedFields.
private static readonly string[] RequiredFields =
{ "Client", "Project", "Hours", "Rate", "Currency", "Date" };
private readonly CounterpartyApiClient _counterpartyClient;
private readonly TradeApiClient _tradeClient;
private readonly CurrencyApiClient _currencyClient;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public ExtractionPlugin(
CounterpartyApiClient counterpartyClient,
TradeApiClient tradeClient,
CurrencyApiClient currencyClient)
{
_counterpartyClient = counterpartyClient;
_tradeClient = tradeClient;
_currencyClient = currencyClient;
}
/// <summary>
/// Validates extracted fields against the predefined schema.
/// Returns a JSON object indicating whether the extraction is valid
/// and listing any errors found.
/// Looks up counterparty candidates by name from the external counterparty API.
/// Returns a JSON object with matching candidates. If multiple candidates match,
/// the agent should present them to the user for selection.
/// </summary>
/// <param name="fieldsJson">
/// JSON string representing the extracted fields. Expected shape:
/// { "Client": "...", "Project": "...", "Hours": 3, ... }
/// </param>
/// <returns>JSON string with { "IsValid": bool, "Errors": [...] }</returns>
[KernelFunction("validate_extracted_fields")]
[Description("Validates extracted key-value fields against the required schema. " +
"Call this after extracting fields from natural language text to check " +
"that all required fields (Client, Project, Hours, Rate, Currency, Date) " +
"are present and correctly typed. Returns validation result with any errors.")]
public string ValidateExtractedFields(
[Description("JSON string of extracted fields")] string fieldsJson)
[KernelFunction("lookup_counterparty")]
[Description("Looks up counterparty/legal entity candidates by name. " +
"Call this after extracting the counterparty name from an email to find the " +
"matching legal entity ID. Returns a list of candidates — if multiple match, " +
"present them to the user as a numbered list and ask which one to use.")]
public async Task<string> LookupCounterparty(
[Description("The counterparty name extracted from the email")] string name)
{
var result = new ValidationResult();
ExtractedFields? fields;
try
{
fields = JsonSerializer.Deserialize<ExtractedFields>(fieldsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var candidates = await _counterpartyClient.LookupAsync(name);
if (candidates.Count == 0)
{
result.IsValid = false;
result.Errors.Add($"No counterparty found matching '{name}'. Ask the user for the correct name.");
}
else if (candidates.Count == 1)
{
result.IsValid = true;
result.Candidates = candidates;
}
else
{
// Multiple matches — agent must ask user to disambiguate
result.IsValid = false;
result.Errors.Add($"Multiple counterparties match '{name}'. Present the candidates to the user and ask them to select one.");
result.Candidates = candidates;
}
}
catch (HttpRequestException ex)
{
result.IsValid = false;
result.Errors.Add($"Counterparty API unavailable: {ex.Message}. Inform the user and proceed without legal entity lookup.");
}
return JsonSerializer.Serialize(result);
}
/// <summary>
/// Validates a Murex trade ID against the external trade validation API.
/// </summary>
[KernelFunction("validate_trade")]
[Description("Validates that a Murex trade ID exists in the system. " +
"Call this for each trade_id extracted from the email to confirm it is valid.")]
public async Task<string> ValidateTrade(
[Description("The Murex trade ID to validate")] long tradeId)
{
var result = new ValidationResult();
try
{
var response = await _tradeClient.ValidateAsync(tradeId);
result.IsValid = response.IsValid;
if (!response.IsValid)
{
result.Errors.Add(response.Message ?? $"Trade ID {tradeId} not found in the system.");
}
}
catch (HttpRequestException ex)
{
result.IsValid = false;
result.Errors.Add($"Trade validation API unavailable: {ex.Message}. Inform the user.");
}
return JsonSerializer.Serialize(result);
}
/// <summary>
/// Validates a currency code against the external currency validation API.
/// </summary>
[KernelFunction("validate_currency")]
[Description("Validates that a currency code is a recognized ISO currency code. " +
"Call this to confirm currency values derived from email content (e.g., '£' mapped to 'GBP').")]
public async Task<string> ValidateCurrency(
[Description("The ISO currency code to validate (e.g., 'GBP', 'USD')")] string currencyCode)
{
var result = new ValidationResult();
try
{
var response = await _currencyClient.ValidateAsync(currencyCode);
result.IsValid = response.IsValid;
if (!response.IsValid)
{
var msg = response.Message ?? $"Currency code '{currencyCode}' is not recognized.";
if (response.Suggestions?.Count > 0)
{
msg += $" Did you mean: {string.Join(", ", response.Suggestions)}?";
}
result.Errors.Add(msg);
}
}
catch (HttpRequestException ex)
{
result.IsValid = false;
result.Errors.Add($"Currency validation API unavailable: {ex.Message}. Inform the user.");
}
return JsonSerializer.Serialize(result);
}
/// <summary>
/// Validates the full ExtractionResult JSON against the TradeItem schema.
/// Checks that all required fields are present and correctly typed.
/// </summary>
[KernelFunction("validate_schema")]
[Description("Validates the complete extraction result JSON against the TradeItem schema. " +
"Call this after building the full extraction output to check all required fields " +
"(valuedate, counterparty, trade_id, display_ccy, pv, breakclause) are present and " +
"correctly typed for every item. legal_entity is optional.")]
public string ValidateSchema(
[Description("The full ExtractionResult JSON string to validate")] string extractionResultJson)
{
var result = new ValidationResult();
ExtractionResult? extraction;
try
{
extraction = JsonSerializer.Deserialize<ExtractionResult>(extractionResultJson, _jsonOptions);
}
catch (JsonException ex)
{
@@ -70,31 +182,38 @@ namespace ChatAgent.Api.Plugins
return JsonSerializer.Serialize(result);
}
if (fields == null)
if (extraction == null || extraction.Items == null || extraction.Items.Count == 0)
{
result.IsValid = false;
result.Errors.Add("Deserialized fields object is null");
result.Errors.Add("ExtractionResult is null or contains no items.");
return JsonSerializer.Serialize(result);
}
// Check each required field for presence and non-empty value
if (string.IsNullOrWhiteSpace(fields.Client))
result.Errors.Add("Missing required field: Client");
for (int i = 0; i < extraction.Items.Count; i++)
{
var item = extraction.Items[i];
var prefix = $"Item[{i}]";
if (string.IsNullOrWhiteSpace(fields.Project))
result.Errors.Add("Missing required field: Project");
if (string.IsNullOrWhiteSpace(item.Valuedate))
result.Errors.Add($"{prefix}: Missing required field 'valuedate'");
if (fields.Hours == null || fields.Hours <= 0)
result.Errors.Add("Missing or invalid required field: Hours (must be a positive number)");
if (string.IsNullOrWhiteSpace(item.Counterparty))
result.Errors.Add($"{prefix}: Missing required field 'counterparty'");
if (fields.Rate == null || fields.Rate <= 0)
result.Errors.Add("Missing or invalid required field: Rate (must be a positive number)");
if (item.TradeId == 0)
result.Errors.Add($"{prefix}: Missing or zero 'trade_id'");
if (string.IsNullOrWhiteSpace(fields.Currency))
result.Errors.Add("Missing required field: Currency");
if (string.IsNullOrWhiteSpace(item.DisplayCcy))
result.Errors.Add($"{prefix}: Missing required field 'display_ccy'");
if (string.IsNullOrWhiteSpace(fields.Date))
result.Errors.Add("Missing required field: Date");
// pv can be 0 (legitimate value), but we check it's been set via the JSON
// No additional validation needed for double — default 0.0 is valid
if (string.IsNullOrWhiteSpace(item.Breakclause))
result.Errors.Add($"{prefix}: Missing required field 'breakclause'");
else if (item.Breakclause != "Y" && item.Breakclause != "N")
result.Errors.Add($"{prefix}: 'breakclause' must be 'Y' or 'N', got '{item.Breakclause}'");
}
result.IsValid = result.Errors.Count == 0;
return JsonSerializer.Serialize(result);

View File

@@ -53,10 +53,50 @@ builder.Services.AddOpenAIChatCompletion(
// any AI services (like the chat completion above) that are already registered.
builder.Services.AddKernel();
// Register the ExtractionPlugin so the Kernel can expose its [KernelFunction] methods
// as tools. When the LLM sees these tools, it can decide to call them during a conversation
// to validate extracted data. The plugin is registered as a singleton via DI.
builder.Services.AddSingleton<ChatAgent.Api.Plugins.ExtractionPlugin>();
// --- External API Typed HttpClients ---
//
// Each extraction tool wraps an external API call. We register typed HttpClients
// so that each service gets its own HttpClient with a pre-configured base URL.
// AddHttpClient<T>() uses IHttpClientFactory under the hood, which manages
// HttpClient lifetimes and avoids socket exhaustion.
builder.Services.AddHttpClient<ChatAgent.Api.Services.CounterpartyApiClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["ExternalApis:CounterpartyBaseUrl"] ?? "http://localhost:5000/api/counterparty");
});
builder.Services.AddHttpClient<ChatAgent.Api.Services.TradeApiClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["ExternalApis:TradeBaseUrl"] ?? "http://localhost:5000/api/trade");
});
builder.Services.AddHttpClient<ChatAgent.Api.Services.CurrencyApiClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["ExternalApis:CurrencyBaseUrl"] ?? "http://localhost:5000/api/currency");
});
// --- Few-Shot Prompting Service ---
//
// FewShotService loads the extraction instruction template and few-shot examples
// from disk at startup. It pre-assembles a ChatHistory prefix that is cloned
// for each extraction request. Registered as a singleton since examples don't
// change at runtime.
// Resolve the examples path relative to the content root so it works correctly
// both when running the app normally and in WebApplicationFactory test contexts.
var examplesRelativePath = builder.Configuration["Examples:FewShotPath"] ?? "examples/extraction";
var examplesAbsolutePath = Path.IsPathRooted(examplesRelativePath)
? examplesRelativePath
: Path.Combine(builder.Environment.ContentRootPath, examplesRelativePath);
builder.Services.AddSingleton(new ChatAgent.Api.Services.FewShotService(examplesAbsolutePath));
// Register the ExtractionPlugin with its typed HttpClient dependencies.
// The plugin exposes [KernelFunction] methods as tools the LLM can call
// to look up counterparties, validate trades, currencies, and the extraction schema.
// Scoped lifetime because it depends on typed HttpClients (which are transient).
builder.Services.AddScoped<ChatAgent.Api.Plugins.ExtractionPlugin>();
// AddCors() registers Cross-Origin Resource Sharing services.
// CORS is REQUIRED because the Blazor WASM client runs on a different origin

View File

@@ -0,0 +1,41 @@
// CounterpartyApiClient.cs -- Typed HttpClient for the external counterparty lookup API.
//
// This service wraps HTTP calls to the counterparty API, which returns candidate
// (counterparty name, legal entity) tuples for a given name search. When multiple
// candidates are returned, the extraction agent presents them to the user for
// disambiguation.
//
// Typed HttpClients are the standard ASP.NET Core pattern for external API calls.
// The base URL is configured via AddHttpClient<T>() in Program.cs, reading from
// appsettings.json. This makes the client easily mockable in tests.
using System.Net.Http.Json;
using ChatAgent.Shared.Models;
namespace ChatAgent.Api.Services
{
/// <summary>
/// Client for the external counterparty lookup API.
/// Returns candidate matches for counterparty name searches.
/// </summary>
public class CounterpartyApiClient
{
private readonly HttpClient _httpClient;
public CounterpartyApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Looks up counterparty candidates by name.
/// Returns a list of candidate matches with name and legal entity ID.
/// </summary>
public async Task<List<CandidateMatch>> LookupAsync(string name)
{
var response = await _httpClient.GetAsync($"lookup?name={Uri.EscapeDataString(name)}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<CandidateMatch>>() ?? new();
}
}
}

View File

@@ -0,0 +1,46 @@
// CurrencyApiClient.cs -- Typed HttpClient for the external currency validation API.
//
// Validates that a currency code is a recognized ISO currency code.
// The extraction agent uses this to confirm that currency values derived
// from email symbols (e.g., "£" → "GBP") are valid.
using System.Net.Http.Json;
namespace ChatAgent.Api.Services
{
/// <summary>
/// Client for the external currency validation API.
/// Validates ISO currency codes.
/// </summary>
public class CurrencyApiClient
{
private readonly HttpClient _httpClient;
public CurrencyApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Validates a currency code against the external API.
/// Returns a result indicating whether the code is valid.
/// </summary>
public async Task<CurrencyValidationResponse> ValidateAsync(string currencyCode)
{
var response = await _httpClient.GetAsync($"validate/{Uri.EscapeDataString(currencyCode)}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CurrencyValidationResponse>()
?? new CurrencyValidationResponse { IsValid = false, Message = "Empty response from currency API" };
}
}
/// <summary>
/// Response from the external currency validation API.
/// </summary>
public class CurrencyValidationResponse
{
public bool IsValid { get; set; }
public string? Message { get; set; }
public List<string>? Suggestions { get; set; }
}
}

View File

@@ -0,0 +1,125 @@
// FewShotService.cs -- Loads few-shot examples from disk and assembles ChatHistory prefixes.
//
// Few-shot prompting injects example input/output pairs as conversation turns in the
// ChatHistory before the real user input. This teaches the model the expected extraction
// behavior by demonstration rather than instruction alone.
//
// The service loads examples once at startup and caches the assembled ChatHistory prefix.
// Each extraction request clones the prefix and appends the real email content.
//
// Key Semantic Kernel concept: ChatHistory is SK's representation of a conversation.
// We build it programmatically with system, user, and assistant messages.
using ChatAgent.Shared.Models;
using Microsoft.SemanticKernel.ChatCompletion;
namespace ChatAgent.Api.Services
{
/// <summary>
/// Loads few-shot examples from disk and provides pre-assembled ChatHistory prefixes
/// for the extraction agent. Registered as a singleton — loads once, clones per request.
/// </summary>
public class FewShotService
{
// The cached ChatHistory prefix: system message + few-shot example turns.
// Cloned for each request so the original is never modified.
private readonly ChatHistory _prefixHistory;
/// <summary>
/// Loads the instruction template and few-shot examples from the specified folder.
/// The folder should contain:
/// - instruction-template.txt (the system prompt)
/// - few-shot/01/input.html + output.json, 02/, 03/, etc.
/// </summary>
public FewShotService(string examplesBasePath)
{
_prefixHistory = new ChatHistory();
// Load the instruction template as the system message.
// This defines the extraction task, schema, and mapping rules.
var templatePath = Path.Combine(examplesBasePath, "instruction-template.txt");
var instructionTemplate = File.ReadAllText(templatePath);
_prefixHistory.AddSystemMessage(instructionTemplate);
// Load few-shot examples from numbered subdirectories.
// Each subdirectory contains input.html (example email) and output.json
// (expected ExtractionResult). They are injected as alternating User/Assistant
// messages so the model treats them as prior successful extractions.
var fewShotPath = Path.Combine(examplesBasePath, "few-shot");
if (Directory.Exists(fewShotPath))
{
var exampleDirs = Directory.GetDirectories(fewShotPath)
.OrderBy(d => Path.GetFileName(d))
.ToList();
foreach (var dir in exampleDirs)
{
var inputPath = Path.Combine(dir, "input.html");
var outputPath = Path.Combine(dir, "output.json");
if (File.Exists(inputPath) && File.Exists(outputPath))
{
var inputHtml = File.ReadAllText(inputPath);
var outputJson = File.ReadAllText(outputPath);
// User message: the example email
_prefixHistory.AddUserMessage(inputHtml);
// Assistant message: the expected extraction output
_prefixHistory.AddAssistantMessage(outputJson);
}
}
}
}
/// <summary>
/// Returns the number of messages in the cached prefix (system + example turns).
/// Useful for testing and diagnostics.
/// </summary>
public int PrefixMessageCount => _prefixHistory.Count;
/// <summary>
/// Creates a ChatHistory for an initial extraction request.
/// Clones the cached prefix and appends the real email as the final user message.
/// </summary>
public ChatHistory CloneWithEmail(string emailHtml)
{
var history = ClonePrefix();
history.AddUserMessage(emailHtml);
return history;
}
/// <summary>
/// Creates a ChatHistory for a follow-up extraction request (e.g., after disambiguation).
/// Clones the cached prefix, appends the original email, then appends all follow-up
/// messages to maintain the full conversation context.
/// </summary>
public ChatHistory CloneWithEmailAndMessages(string emailHtml, List<ChatMessage> messages)
{
var history = ClonePrefix();
history.AddUserMessage(emailHtml);
foreach (var msg in messages)
{
if (msg.Role == "user")
history.AddUserMessage(msg.Content);
else if (msg.Role == "assistant")
history.AddAssistantMessage(msg.Content);
}
return history;
}
/// <summary>
/// Deep-clones the cached prefix ChatHistory so the original is not modified.
/// </summary>
private ChatHistory ClonePrefix()
{
var clone = new ChatHistory();
foreach (var message in _prefixHistory)
{
clone.Add(message);
}
return clone;
}
}
}

View File

@@ -0,0 +1,45 @@
// TradeApiClient.cs -- Typed HttpClient for the external trade validation API.
//
// Verifies that a Murex trade ID exists in the system. Returns a simple
// valid/invalid result that the extraction agent uses to confirm extracted
// trade IDs are real.
using System.Net.Http.Json;
namespace ChatAgent.Api.Services
{
/// <summary>
/// Client for the external trade validation API.
/// Validates that trade IDs exist in the system.
/// </summary>
public class TradeApiClient
{
private readonly HttpClient _httpClient;
public TradeApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Validates a trade ID against the external API.
/// Returns a result indicating whether the trade exists.
/// </summary>
public async Task<TradeValidationResponse> ValidateAsync(long tradeId)
{
var response = await _httpClient.GetAsync($"validate/{tradeId}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TradeValidationResponse>()
?? new TradeValidationResponse { IsValid = false, Message = "Empty response from trade API" };
}
}
/// <summary>
/// Response from the external trade validation API.
/// </summary>
public class TradeValidationResponse
{
public bool IsValid { get; set; }
public string? Message { get; set; }
}
}

View File

@@ -9,5 +9,13 @@
"ResponsesApi": {
"BaseUrl": "http://localhost:8317/v1",
"Model": "claude-sonnet-4-6"
},
"Examples": {
"FewShotPath": "../../examples/extraction"
},
"ExternalApis": {
"CounterpartyBaseUrl": "http://localhost:5000/api/counterparty",
"TradeBaseUrl": "http://localhost:5000/api/trade",
"CurrencyBaseUrl": "http://localhost:5000/api/currency"
}
}

View File

@@ -5,9 +5,11 @@
- MudPopoverProvider: Manages popover/dropdown positioning (used by MudSelect, MudMenu, etc.)
- MudDialogProvider: Enables the dialog service to render modal dialogs
The layout uses MudLayout + MudAppBar + MudMainContent to create a standard
Material Design app shell. MudMainContent automatically accounts for the AppBar
height so page content doesn't render underneath it.
The layout uses MudLayout + MudAppBar + MudDrawer + MudMainContent to create a standard
Material Design app shell with sidebar navigation:
- MudAppBar: top bar with hamburger toggle and title
- MudDrawer: collapsible sidebar containing the NavMenu
- MudMainContent: renders the routed page content via @Body
*@
@inherits LayoutComponentBase
@@ -17,15 +19,44 @@
<MudDialogProvider />
<MudLayout>
@* MudAppBar provides the top application bar. Dense reduces its height.
The fixed position keeps it visible while scrolling. *@
@* MudAppBar provides the top application bar. Dense reduces its height to 48px.
The hamburger icon toggles the sidebar drawer open/closed. *@
<MudAppBar Elevation="1" Dense="true">
@* MudIconButton with the Menu icon acts as the hamburger toggle.
Clicking it flips the _drawerOpen bool, which MudDrawer binds to. *@
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6">Chat Agent</MudText>
</MudAppBar>
@* MudDrawer creates a sidebar panel that slides in/out.
- ClipMode.Always: the drawer sits below the AppBar, not beside it
- Elevation 2 gives a subtle shadow separating it from the main content
- Open is bound to _drawerOpen so the hamburger button controls visibility
- The NavMenu component inside provides the navigation links *@
<MudDrawer @bind-Open="_drawerOpen"
ClipMode="DrawerClipMode.Always"
Elevation="2">
<NavMenu />
</MudDrawer>
@* MudMainContent renders the routed page content (same role as @Body in plain Blazor).
It automatically adds top padding to clear the AppBar. *@
It automatically adds top padding to clear the AppBar and left margin for the drawer. *@
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>
@code {
// Controls whether the sidebar drawer is open or collapsed.
// Defaults to true so the drawer is visible on first load.
private bool _drawerOpen = true;
// Toggles the drawer open/closed. Called by the hamburger icon in the AppBar.
private void ToggleDrawer()
{
_drawerOpen = !_drawerOpen;
}
}

View File

@@ -0,0 +1,24 @@
@* NavMenu.razor -- Sidebar navigation menu for the application.
This component renders inside the MudDrawer in MainLayout.
It uses MudNavMenu to provide a Material Design navigation list.
MudNavMenu is a container for MudNavLink items. Each MudNavLink uses
Blazor's built-in NavigationManager under the hood — when clicked,
it navigates to the specified href without a full page reload.
The Match="NavLinkMatch.All" attribute ensures the link is only
highlighted when the URL matches exactly (not just the prefix).
*@
<MudNavMenu>
@* Each MudNavLink defines a navigation item in the sidebar.
- Href: the route to navigate to (Blazor client-side routing, no server round-trip)
- Icon: Material Design icon displayed beside the text
- Match: NavLinkMatch.All means the link is "active" only when the URL matches exactly *@
<MudNavLink Href="/sales-assistant"
Icon="@Icons.Material.Filled.SmartToy"
Match="NavLinkMatch.All">
Sales Assistant
</MudNavLink>
</MudNavMenu>

View File

@@ -1,25 +1,30 @@
@* Chat.razor -- The main chat interface with streaming AI responses.
@* Chat.razor -- The main chat interface with streaming AI responses and email extraction.
This is the primary page of the application, mapped to the root route "/".
It displays a vertically scrolling message list and a text input at the bottom,
styled after ChatGPT/Gemini.
This page uses MudTabs to provide three panels:
1. Chat — the conversation UI with message list, input, and email upload
2. System Prompt — editable text area for the AI system prompt
3. Model Settings — temperature, top-p, and max tokens controls
Key Blazor concepts demonstrated:
- @page routing (this component owns "/")
- Two-way binding with @bind-Value on MudTextField
- @page routing
- MudTabs for tabbed layout (KeepPanelsAlive preserves state across tab switches)
- Two-way binding with @bind-Value on MudTextField and MudNumericField
- Event handling with @onclick and OnKeyDown
- List rendering with @foreach over a List<T>
- StateHasChanged() for manual re-render triggers during streaming
- IJSRuntime for calling JavaScript (auto-scroll)
- IJSRuntime for calling JavaScript (auto-scroll, drag-and-drop file handling)
- IAsyncEnumerable consumption for streaming API responses
- Conditional rendering for thinking indicator and error states
- IDisposable for cleaning up JS interop references
- InputFile component for file picker upload
- DotNetObjectReference for JS-to-.NET callbacks
*@
@page "/"
@page "/sales-assistant"
@* IJSRuntime lets us call JavaScript from C#. We use it to scroll the message
container to the bottom after adding a new message, because Blazor has no
built-in scroll API. *@
@implements IDisposable
@* IJSRuntime lets us call JavaScript from C#. We use it for auto-scroll
and drag-and-drop file handling via JS interop. *@
@inject IJSRuntime JS
@* ChatApiClient is our typed HttpClient wrapper that handles API communication.
@@ -29,98 +34,199 @@
@* MarkdownService converts markdown to sanitized HTML for rendering assistant messages.
We use it to transform the raw LLM output into formatted HTML before display. *@
@using ChatAgent.Client.Services
@using ChatAgent.Shared.Models
@inject MarkdownService Markdown
<PageTitle>Chat Agent</PageTitle>
<PageTitle>Sales Assistant</PageTitle>
@* Chat container: uses flexbox to fill available height.
The message area grows to fill space; the input stays pinned at the bottom. *@
<div class="chat-container">
@* MudTabs provides the tabbed layout. KeepPanelsAlive="true" ensures that
switching tabs doesn't destroy and recreate panel content — the system prompt
text and model settings values are preserved across tab switches.
Elevation and Rounded give the tabs a subtle Material Design appearance. *@
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true"
Class="tab-container" KeepPanelsAlive="true">
@* Message list: scrollable area that grows to fill available space. *@
<div class="message-list" @ref="_messageListRef">
@if (_messages.Count == 0)
{
@* Empty state shown before any messages are sent *@
<div class="empty-state">
<MudText Typo="Typo.h5" Align="Align.Center" Class="mb-2"
Style="color: var(--mud-palette-text-secondary);">
Chat Agent
</MudText>
<MudText Typo="Typo.body2" Align="Align.Center"
Style="color: var(--mud-palette-text-disabled);">
Type a message to get started
</MudText>
</div>
}
else
{
@* Render each message as a MudPaper card.
The CSS class changes based on Role to align user messages right, assistant left. *@
@foreach (var message in _messages)
@* --- Chat Tab: the main conversation UI --- *@
<MudTabPanel Text="Chat" Icon="@Icons.Material.Filled.Chat">
<div class="chat-container">
@* Extraction mode indicator — shows when an email has been uploaded
and subsequent messages route to the extraction endpoint. *@
@if (_isExtractionMode)
{
<div class="message-row @(message.Role == "user" ? "message-user" : "message-assistant")">
<MudPaper Class="@($"message-bubble {(message.Role == "user" ? "bubble-user" : "bubble-assistant")}")"
Elevation="0">
@if (message.Role == "assistant" && string.IsNullOrEmpty(message.Content) && _isStreaming)
{
@* Thinking indicator: shown while waiting for the first token.
MudProgressCircular gives an animated spinner that disappears
once the first text delta arrives. *@
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else if (message.Role == "assistant")
{
@* Assistant messages are rendered as HTML converted from markdown.
MarkupString tells Blazor to treat the string as raw HTML rather
than escaping it. The MarkdownService sanitizes the HTML to prevent XSS.
We use GetRenderedHtml() to cache completed messages and avoid
re-running Markdig on every StateHasChanged during streaming. *@
<div class="markdown-body">
@((MarkupString)GetRenderedHtml(message))
</div>
}
else
{
@* User messages stay as plain text — no markdown processing. *@
<MudText Typo="Typo.body1">@message.Content</MudText>
}
</MudPaper>
<div class="extraction-indicator">
<MudChip T="string" Icon="@Icons.Material.Filled.Email"
Color="Color.Info" Size="Size.Small">
Extraction Mode
</MudChip>
</div>
}
}
</div>
@* Input area: pinned at the bottom of the chat container.
Disabled attribute prevents interaction while the assistant is streaming.
The "New Chat" button clears the conversation to start fresh. *@
<div class="input-area">
@if (_messages.Count > 0)
{
<div class="input-actions">
<MudButton Variant="Variant.Text"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="NewChat"
Disabled="_isStreaming">
New Chat
</MudButton>
@* Message list: scrollable area that grows to fill available space.
Also serves as the drag-and-drop target for email files.
The drag-over class provides visual feedback during file drags. *@
<div class="message-list @(_isDragOver ? "drag-over" : "")" @ref="_messageListRef">
@if (_messages.Count == 0)
{
@* Empty state shown before any messages are sent *@
<div class="empty-state">
<MudText Typo="Typo.h5" Align="Align.Center" Class="mb-2"
Style="color: var(--mud-palette-text-secondary);">
Sales Assistant
</MudText>
<MudText Typo="Typo.body2" Align="Align.Center"
Style="color: var(--mud-palette-text-disabled);">
Type a message or drop an email (.html) to extract trade data
</MudText>
</div>
}
else
{
@foreach (var message in _messages)
{
<div class="message-row @(message.Role == "user" ? "message-user" : "message-assistant")">
<MudPaper Class="@($"message-bubble {(message.Role == "user" ? "bubble-user" : "bubble-assistant")}")"
Elevation="0">
@if (message.Role == "assistant" && string.IsNullOrEmpty(message.Content) && _isStreaming)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else if (message.Role == "assistant")
{
<div class="markdown-body">
@((MarkupString)GetRenderedHtml(message))
</div>
}
else
{
<MudText Typo="Typo.body1">@message.Content</MudText>
}
</MudPaper>
</div>
}
}
@* Drag-over overlay text — visible only when dragging a file over the area *@
@if (_isDragOver)
{
<div class="drag-overlay">
<MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" />
<MudText Typo="Typo.h6">Drop email here</MudText>
</div>
}
</div>
}
<MudTextField @bind-Value="_userInput"
Placeholder="@(_isStreaming ? "Waiting for response..." : "Type a message...")"
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentIcon="@Icons.Material.Filled.Send"
AdornmentColor="@(_isStreaming ? Color.Default : Color.Primary)"
OnAdornmentClick="SendMessage"
OnKeyDown="HandleKeyDown"
Immediate="true"
FullWidth="true"
AutoFocus="true"
Disabled="_isStreaming" />
</div>
</div>
@* Input area: pinned at the bottom of the chat container. *@
<div class="input-area">
@if (_messages.Count > 0)
{
<div class="input-actions">
<MudButton Variant="Variant.Text"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="NewChat"
Disabled="_isStreaming">
New Chat
</MudButton>
</div>
}
<div class="input-row">
@* Hidden InputFile for the file picker. Accept only .html files.
Triggered programmatically by the upload button below. *@
<InputFile @ref="_inputFile"
OnChange="HandleFileSelected"
accept=".html"
style="display: none"
id="email-file-input" />
@* Upload button — opens the file picker dialog for .html email files *@
<MudIconButton Icon="@Icons.Material.Filled.AttachEmail"
Color="@(_isStreaming ? Color.Default : Color.Primary)"
Disabled="_isStreaming"
OnClick="TriggerFilePicker" />
<MudTextField @bind-Value="_userInput"
Placeholder="@(_isStreaming ? "Waiting for response..." : "Type a message...")"
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentIcon="@Icons.Material.Filled.Send"
AdornmentColor="@(_isStreaming ? Color.Default : Color.Primary)"
OnAdornmentClick="SendMessage"
OnKeyDown="HandleKeyDown"
Immediate="true"
FullWidth="true"
AutoFocus="true"
Disabled="_isStreaming" />
</div>
</div>
@* Error message display for file upload issues *@
@if (!string.IsNullOrEmpty(_uploadError))
{
<MudAlert Severity="Severity.Warning" Class="mx-4 mb-2"
CloseIconClicked="() => _uploadError = string.Empty"
ShowCloseIcon="true">
@_uploadError
</MudAlert>
}
</div>
</MudTabPanel>
@* --- System Prompt Tab: editable system message for the AI --- *@
<MudTabPanel Text="System Prompt" Icon="@Icons.Material.Filled.Description">
<div class="settings-panel">
<MudText Typo="Typo.body2" Class="mb-3"
Style="color: var(--mud-palette-text-secondary);">
The system prompt is sent as the first message to the AI, defining its persona,
behavior, and constraints. It is included with every chat request.
</MudText>
@* Multi-line text area for the system prompt. Lines="10" sets the visible
height. @bind-Value two-way binds to the _systemPrompt field so the value
persists across tab switches (KeepPanelsAlive). *@
<MudTextField @bind-Value="_systemPrompt"
Label="System Prompt"
Placeholder="e.g., You are a helpful sales assistant. Be concise and professional."
Variant="Variant.Outlined"
Lines="10"
FullWidth="true" />
</div>
</MudTabPanel>
@* --- Model Settings Tab: temperature, top-p, max tokens --- *@
<MudTabPanel Text="Model Settings" Icon="@Icons.Material.Filled.Tune">
<div class="settings-panel">
<MudText Typo="Typo.body2" Class="mb-3"
Style="color: var(--mud-palette-text-secondary);">
Adjust model parameters to control response behavior. Leave blank to use defaults.
</MudText>
@* MudNumericField provides a number input with min/max validation.
The generic type parameter <double?> makes the field nullable —
an empty field sends null (use default) rather than 0. *@
<MudNumericField @bind-Value="_temperature"
Label="Temperature"
HelperText="Controls randomness (0.0 = deterministic, 2.0 = very random). Default: 1.0"
Variant="Variant.Outlined"
Min="0.0" Max="2.0" Step="0.1"
Class="mb-4" />
<MudNumericField @bind-Value="_topP"
Label="Top P"
HelperText="Nucleus sampling threshold (0.01.0). Lower = more focused. Default: 1.0"
Variant="Variant.Outlined"
Min="0.0" Max="1.0" Step="0.1"
Class="mb-4" />
<MudNumericField @bind-Value="_maxTokens"
Label="Max Tokens"
HelperText="Maximum response length in tokens (14096). Leave blank for model default."
Variant="Variant.Outlined"
Min="1" Max="4096"
Class="mb-4" />
</div>
</MudTabPanel>
</MudTabs>
@code {
// The conversation messages, displayed in the message list.
@@ -142,6 +248,239 @@
// message is re-rendered; completed messages use their cached HTML.
private readonly Dictionary<ChatMessage, string> _renderedHtmlCache = new();
// --- Prompt & Model Settings ---
// These fields persist across tab switches because MudTabs KeepPanelsAlive="true"
// preserves component state. Values are included in each ChatRequest.
// System prompt text — sent as the first system message to the AI.
// Default provides a reasonable starting point that the user can modify.
private string _systemPrompt = "You are a helpful assistant. Be concise and professional.";
// Model parameters with their OpenAI/SK defaults pre-filled so the user
// can see exactly what values are being sent to the model.
private double? _temperature = 1.0;
private double? _topP = 1.0;
private int? _maxTokens;
// --- Email Upload & Extraction Mode ---
// When an email is uploaded (via drag-drop or file picker), the conversation
// enters extraction mode. Subsequent messages route to the extraction endpoint
// instead of the general chat endpoint.
// Whether the conversation is in extraction mode (email uploaded).
private bool _isExtractionMode = false;
// The HTML content of the uploaded email, stored for follow-up requests.
private string _emailHtml = string.Empty;
// Visual feedback for drag-and-drop — true when a file is being dragged over the area.
private bool _isDragOver = false;
// Error message for invalid file uploads (e.g., non-.html files).
private string _uploadError = string.Empty;
// Reference to the InputFile component for programmatic triggering.
private InputFile? _inputFile;
// DotNetObjectReference for JS-to-.NET callbacks (drag-and-drop).
// Must be disposed to avoid memory leaks.
private DotNetObjectReference<Chat>? _dotNetRef;
/// <summary>
/// After the component renders, register the drag-and-drop JS interop
/// on the message list element. Only register once (firstRender).
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
try
{
await JS.InvokeVoidAsync("fileDrop.register", _dotNetRef, ".message-list");
}
catch
{
// JS interop may fail during prerendering — non-critical
}
}
}
/// <summary>
/// JS callback: user dragged a file over the drop zone.
/// </summary>
[JSInvokable]
public void OnDragEnter()
{
if (_isStreaming) return;
_isDragOver = true;
StateHasChanged();
}
/// <summary>
/// JS callback: user's drag left the drop zone.
/// </summary>
[JSInvokable]
public void OnDragLeave()
{
_isDragOver = false;
StateHasChanged();
}
/// <summary>
/// JS callback: user dropped a file on the drop zone.
/// Validates the file type and triggers extraction.
/// </summary>
[JSInvokable]
public async Task OnFileDrop(string fileName, string content)
{
_isDragOver = false;
if (_isStreaming)
{
StateHasChanged();
return;
}
if (!fileName.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
{
_uploadError = $"Unsupported file type: {fileName}. Only .html files are supported.";
StateHasChanged();
return;
}
_uploadError = string.Empty;
await ProcessEmailUpload(fileName, content);
}
/// <summary>
/// Opens the file picker dialog by clicking the hidden InputFile element.
/// </summary>
private async Task TriggerFilePicker()
{
try
{
await JS.InvokeVoidAsync("eval",
"document.getElementById('email-file-input').click()");
}
catch
{
// Ignore if JS interop fails
}
}
/// <summary>
/// Handles file selection from the InputFile component (file picker button).
/// Reads the file content and triggers extraction.
/// </summary>
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file == null) return;
if (!file.Name.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
{
_uploadError = $"Unsupported file type: {file.Name}. Only .html files are supported.";
StateHasChanged();
return;
}
_uploadError = string.Empty;
// Read the file content as a string (max 10MB to prevent memory issues)
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var reader = new System.IO.StreamReader(stream);
var content = await reader.ReadToEndAsync();
await ProcessEmailUpload(file.Name, content);
}
/// <summary>
/// Processes an uploaded email file: enters extraction mode, adds an upload
/// message to the chat, and sends the initial extraction request.
/// </summary>
private async Task ProcessEmailUpload(string fileName, string content)
{
// Enter extraction mode
_isExtractionMode = true;
_emailHtml = content;
// Show upload message in the chat
_messages.Add(new ChatMessage
{
Role = "user",
Content = $"[Uploaded: {fileName}]",
Timestamp = DateTime.UtcNow
});
StateHasChanged();
await ScrollToBottom();
// Send initial extraction request
await SendExtractionMessage();
}
/// <summary>
/// Sends an extraction request with the uploaded email and any follow-up messages.
/// Streams the agent's response token by token (same pattern as SendMessage).
/// </summary>
private async Task SendExtractionMessage()
{
_isStreaming = true;
var assistantMessage = new ChatMessage
{
Role = "assistant",
Content = string.Empty,
Timestamp = DateTime.UtcNow
};
_messages.Add(assistantMessage);
StateHasChanged();
await ScrollToBottom();
try
{
// Build the extraction request with the email and any follow-up messages.
// Exclude empty messages and the upload placeholder.
var conversationMessages = _messages
.Where(m => !string.IsNullOrEmpty(m.Content)
&& !m.Content.StartsWith("[Uploaded:"))
.Select(m => new ChatMessage { Role = m.Role, Content = m.Content, Timestamp = m.Timestamp })
.ToList();
// Remove the empty assistant placeholder from the messages list
conversationMessages = conversationMessages
.Where(m => !string.IsNullOrEmpty(m.Content))
.ToList();
var request = new ExtractionRequest
{
EmailHtml = _emailHtml,
Messages = conversationMessages
};
await foreach (var delta in ApiClient.SendExtractionStreamingAsync(request))
{
assistantMessage.Content += delta;
StateHasChanged();
await ScrollToBottom();
}
}
catch (Exception ex)
{
assistantMessage.Content = $"Error: {ex.Message}";
}
finally
{
_isStreaming = false;
assistantMessage.Timestamp = DateTime.UtcNow;
_renderedHtmlCache[assistantMessage] = Markdown.ConvertToHtml(assistantMessage.Content);
StateHasChanged();
await ScrollToBottom();
}
}
/// <summary>
/// Returns cached rendered HTML for completed messages, or renders fresh
/// for the actively streaming message. This avoids re-running Markdig on
@@ -159,12 +498,16 @@
/// <summary>
/// Clears all messages to start a new conversation.
/// Resets extraction mode so the next message routes to general chat.
/// </summary>
private void NewChat()
{
_messages.Clear();
_renderedHtmlCache.Clear();
_userInput = string.Empty;
_isExtractionMode = false;
_emailHtml = string.Empty;
_uploadError = string.Empty;
}
/// <summary>
@@ -180,7 +523,7 @@
/// <summary>
/// Sends the user's message and streams the AI response token by token.
/// Each token delta updates the assistant message and triggers a re-render.
/// In extraction mode, routes to the extraction endpoint instead of general chat.
/// </summary>
private async Task SendMessage()
{
@@ -196,8 +539,15 @@
Timestamp = DateTime.UtcNow
});
var userText = _userInput.Trim();
_userInput = string.Empty;
// If in extraction mode, route to the extraction endpoint for disambiguation
if (_isExtractionMode)
{
await SendExtractionMessage();
return;
}
_isStreaming = true;
// Add an empty assistant message that will be filled token by token.
@@ -216,36 +566,36 @@
try
{
// Build the request with the full conversation history for multi-turn context.
// We include all messages except the last one (the empty assistant placeholder
// that is waiting to be filled by the stream). This gives the AI the full
// conversation so it can reference prior exchanges.
// SystemPrompt and Settings are included if the user has configured them
// in the System Prompt and Model Settings tabs.
var request = new ChatRequest
{
Messages = _messages
.Where(m => !string.IsNullOrEmpty(m.Content))
.Select(m => new ChatMessage { Role = m.Role, Content = m.Content, Timestamp = m.Timestamp })
.ToList()
.ToList(),
SystemPrompt = string.IsNullOrWhiteSpace(_systemPrompt) ? null : _systemPrompt,
Settings = (_temperature.HasValue || _topP.HasValue || _maxTokens.HasValue)
? new ModelSettings
{
Temperature = _temperature,
TopP = _topP,
MaxTokens = _maxTokens
}
: null
};
// Stream tokens from the API. IAsyncEnumerable yields each text delta
// as it arrives, allowing us to update the UI incrementally.
await foreach (var delta in ApiClient.SendChatStreamingAsync(request))
{
// Append each token to the assistant message content.
assistantMessage.Content += delta;
// StateHasChanged() triggers a re-render so the user sees each token appear.
// This is the core of the streaming UX — without it, the full response
// would only appear after the stream completes.
StateHasChanged();
// Auto-scroll during streaming so new content stays visible
await ScrollToBottom();
}
}
catch (Exception ex)
{
// If the API call fails, show the error in the assistant message.
assistantMessage.Content = $"Error: {ex.Message}";
}
finally
@@ -276,4 +626,13 @@
// Ignore scroll errors — non-critical UI enhancement
}
}
/// <summary>
/// Disposes the DotNetObjectReference to prevent memory leaks.
/// Required by IDisposable — Blazor calls this when the component is removed.
/// </summary>
public void Dispose()
{
_dotNetRef?.Dispose();
}
}

View File

@@ -9,16 +9,37 @@
* because those elements are rendered by MudBlazor, not directly by this component.
*/
/* Chat container: flexbox column that fills the viewport below the AppBar.
/* Tab container: fills the viewport below the AppBar.
* MudTabs renders a header (~48px) plus the active panel content. */
::deep .tab-container {
height: calc(100vh - 48px); /* 48px = MudAppBar Dense height */
display: flex;
flex-direction: column;
}
/* Ensure the tab panels area fills remaining space below the tab header */
::deep .tab-container .mud-tabs-panels {
flex: 1;
overflow: hidden;
}
/* Chat container: flexbox column that fills the tab panel.
* The message-list grows to fill available space; input-area stays at the bottom. */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 48px); /* 48px = MudAppBar Dense height */
height: 100%;
max-width: 800px;
margin: 0 auto;
}
/* Settings panels: scrollable content area with padding */
.settings-panel {
max-width: 600px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
/* Scrollable message area */
.message-list {
flex: 1;
@@ -177,3 +198,44 @@
justify-content: flex-end;
margin-bottom: 0.25rem;
}
/* Input row: upload button + text field side by side */
.input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* --- Drag-and-drop visual feedback ---
* When a file is dragged over the message list, show a highlighted border
* and an overlay with "Drop email here" text. */
.message-list.drag-over {
border: 2px dashed var(--mud-palette-primary);
background-color: rgba(var(--mud-palette-primary-rgb), 0.05);
position: relative;
}
/* Overlay shown during drag-over with icon and text */
.drag-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
background-color: rgba(var(--mud-palette-primary-rgb), 0.08);
color: var(--mud-palette-primary);
pointer-events: none;
z-index: 10;
}
/* --- Extraction mode indicator ---
* Small chip/banner at the top of the chat area showing when
* the conversation is in extraction mode. */
.extraction-indicator {
display: flex;
justify-content: center;
padding: 0.25rem 0;
}

View File

@@ -0,0 +1,19 @@
@* Index.razor -- Root URL redirect.
This component owns the "/" route and immediately redirects to /sales-assistant.
It exists so that navigating to the root URL doesn't show a blank page.
NavigationManager.NavigateTo performs client-side navigation in Blazor —
no server round-trip occurs. The replace: true parameter replaces the
current history entry so the user can't "back" into the redirect.
*@
@page "/"
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
Navigation.NavigateTo("/sales-assistant", replace: true);
}
}

View File

@@ -141,5 +141,76 @@ namespace ChatAgent.Client.Services
}
}
}
/// <summary>
/// Sends an extraction request to POST /api/chat/extract and streams the response
/// as an async enumerable of text deltas. Uses the same SSE format as SendChatStreamingAsync.
///
/// The extraction endpoint uses a few-shot ChatHistory (not the user system prompt)
/// and loads extraction-specific tools (counterparty lookup, validation, etc.).
/// </summary>
public async IAsyncEnumerable<string> SendExtractionStreamingAsync(ExtractionRequest request)
{
var jsonContent = JsonSerializer.Serialize(request);
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "api/chat/extract")
{
Content = new StringContent(jsonContent, Encoding.UTF8, "application/json")
};
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)
{
if (!line.StartsWith("data: "))
continue;
var data = line.Substring(6);
if (data == "[DONE]")
yield break;
string? parsedText = null;
string? parsedError = null;
try
{
using var doc = JsonDocument.Parse(data);
var root = doc.RootElement;
if (root.TryGetProperty("error", out var errorElement))
{
parsedError = errorElement.GetString();
}
else if (root.TryGetProperty("text", out var textElement))
{
parsedText = textElement.GetString();
}
}
catch (JsonException)
{
// Skip malformed SSE data
}
if (parsedError != null)
{
throw new HttpRequestException($"Extraction API error: {parsedError}");
}
if (!string.IsNullOrEmpty(parsedText))
{
yield return parsedText;
}
}
}
}
}

View File

@@ -35,6 +35,9 @@
<!-- MudBlazor JS — required for popover positioning, scroll handling, etc. -->
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<!-- File drag-and-drop interop for email upload -->
<script src="js/file-drop.js"></script>
</body>
</html>

View File

@@ -0,0 +1,56 @@
// file-drop.js -- JavaScript interop for drag-and-drop file handling.
//
// Blazor WASM has limited built-in drag-and-drop support. This JS module
// handles the browser's drag/drop events, reads the dropped file as text,
// and calls back into .NET with the filename and content.
//
// The .NET side registers a DotNetObjectReference that receives callbacks
// for dragenter, dragleave, and file drop events.
window.fileDrop = {
// Registers drag-and-drop event handlers on the specified element.
// dotNetRef: a DotNetObjectReference<T> for callbacks to .NET
// elementSelector: CSS selector for the drop target element
register: function (dotNetRef, elementSelector) {
const element = document.querySelector(elementSelector);
if (!element) return;
// Prevent default browser behavior for drag events (which would
// navigate away or open the file). Required for drop to work.
element.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
});
element.addEventListener('dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
dotNetRef.invokeMethodAsync('OnDragEnter');
});
element.addEventListener('dragleave', function (e) {
e.preventDefault();
e.stopPropagation();
// Only fire if leaving the element itself, not a child
if (e.target === element) {
dotNetRef.invokeMethodAsync('OnDragLeave');
}
});
element.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
dotNetRef.invokeMethodAsync('OnDragLeave');
const files = e.dataTransfer.files;
if (files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onload = function () {
dotNetRef.invokeMethodAsync('OnFileDrop', file.name, reader.result);
};
reader.readAsText(file);
});
}
};

View File

@@ -6,9 +6,8 @@
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Request payload for POST /api/chat. Contains the conversation messages
/// to send to the AI model. Currently single-turn (one user message),
/// but the list structure supports multi-turn in future phases.
/// Request payload for POST /api/chat. Contains the conversation messages,
/// an optional system prompt, and optional model parameter overrides.
/// </summary>
public class ChatRequest
{
@@ -17,5 +16,18 @@ namespace ChatAgent.Shared.Models
/// and Content (the text). The API forwards these to the Responses API.
/// </summary>
public List<ChatMessage> Messages { get; set; } = new();
/// <summary>
/// Optional system prompt. When non-empty, the API inserts this as the first
/// system message in the ChatHistory before the conversation messages.
/// System prompts define the AI's persona, behavior, and constraints.
/// </summary>
public string? SystemPrompt { get; set; }
/// <summary>
/// Optional model parameter overrides (temperature, top-p, max tokens).
/// Null means use server defaults. Only non-null fields override defaults.
/// </summary>
public ModelSettings? Settings { get; set; }
}
}

View File

@@ -1,41 +0,0 @@
// ExtractedFields.cs -- Strongly-typed schema for structured data extraction.
//
// This class defines the predefined set of key-value fields that the AI agent
// extracts from natural language input (e.g., email text). All fields are known
// at compile time. Required fields must be non-null for validation to pass.
//
// Placeholder fields are used until the real schema is provided.
namespace ChatAgent.Shared.Models
{
/// <summary>
/// The fixed set of fields the agent extracts from natural language input.
/// Required fields are marked with comments; optional fields may be null.
/// </summary>
public class ExtractedFields
{
/// <summary>Client or company name (required).</summary>
public string? Client { get; set; }
/// <summary>Project or engagement name (required).</summary>
public string? Project { get; set; }
/// <summary>Number of hours worked (required).</summary>
public decimal? Hours { get; set; }
/// <summary>Hourly rate (required).</summary>
public decimal? Rate { get; set; }
/// <summary>Currency code, e.g. "USD", "GBP" (required).</summary>
public string? Currency { get; set; }
/// <summary>Date of work or service (required). ISO 8601 format preferred.</summary>
public string? Date { get; set; }
/// <summary>Description of work performed (optional).</summary>
public string? Description { get; set; }
/// <summary>Purchase order number (optional).</summary>
public string? PoNumber { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
// ExtractionRequest.cs -- DTO sent from the WASM client to trigger email extraction.
//
// This lives in ChatAgent.Shared so both client and API agree on the request shape.
// The first request contains only the email HTML. Follow-up requests (e.g., after
// disambiguation) include the growing conversation Messages list.
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Request payload for POST /api/chat/extract. Contains the email HTML content
/// and optional follow-up conversation messages for disambiguation.
/// </summary>
public class ExtractionRequest
{
/// <summary>
/// The HTML content of the email to extract trade data from.
/// Required for every extraction request.
/// </summary>
public string EmailHtml { get; set; } = string.Empty;
/// <summary>
/// Follow-up conversation messages for disambiguation.
/// Empty on the first request. On follow-ups (e.g., user selecting a counterparty),
/// this contains the full assistant/user exchange since extraction started.
/// </summary>
public List<ChatMessage> Messages { get; set; } = new();
}
}

View File

@@ -0,0 +1,21 @@
// ExtractionResult.cs -- Wrapper for structured extraction output from a sales email.
//
// A single email may contain multiple swaps with multiple legs, producing
// multiple TradeItem objects. This wrapper collects them into one response.
// The JSON output has the shape: {"items": [{...}, {...}, ...]}
using System.Text.Json.Serialization;
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Contains all trade items extracted from a single email.
/// Each trade leg becomes a separate TradeItem in the Items list.
/// </summary>
public class ExtractionResult
{
/// <summary>The extracted trade items, one per trade leg.</summary>
[JsonPropertyName("items")]
public List<TradeItem> Items { get; set; } = new();
}
}

View File

@@ -0,0 +1,33 @@
// ModelSettings.cs -- Optional model parameter overrides sent with chat requests.
//
// All fields are nullable — null means "use the server/model default".
// This allows the client to override only specific parameters without
// needing to know or specify all defaults.
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Optional model parameters for the AI chat completion request.
/// Null fields are ignored — only non-null values override the defaults.
/// </summary>
public class ModelSettings
{
/// <summary>
/// Controls randomness in the response. Range: 0.0 (deterministic) to 2.0 (very random).
/// Higher values produce more creative but less predictable output.
/// </summary>
public double? Temperature { get; set; }
/// <summary>
/// Nucleus sampling: only tokens in the top P probability mass are considered.
/// Range: 0.0 to 1.0. Lower values focus on more likely tokens.
/// </summary>
public double? TopP { get; set; }
/// <summary>
/// Maximum number of tokens to generate in the response.
/// Range: 1 to 4096. Limits response length.
/// </summary>
public int? MaxTokens { get; set; }
}
}

View File

@@ -0,0 +1,52 @@
// TradeItem.cs -- Represents a single trade leg extracted from a sales email.
//
// Each email may contain multiple swaps, each with multiple legs (e.g., Coupon Leg,
// APD Leg). Every leg with a unique Murex trade ID becomes a separate TradeItem.
// The extraction agent flattens the nested swap/leg structure into this flat model.
//
// JSON serialization uses snake_case property names via [JsonPropertyName] to match
// the expected output format consumed by downstream systems and external APIs.
using System.Text.Json.Serialization;
namespace ChatAgent.Shared.Models
{
/// <summary>
/// A single trade leg extracted from a sales email.
/// All fields except LegalEntity are required for a valid extraction.
/// LegalEntity is populated after counterparty disambiguation via the lookup tool.
/// </summary>
public class TradeItem
{
/// <summary>Value date in dd/MM/yyyy format, parsed from email date references.</summary>
[JsonPropertyName("valuedate")]
public string? Valuedate { get; set; }
/// <summary>Full legal name of the counterparty as stated in the email.</summary>
[JsonPropertyName("counterparty")]
public string? Counterparty { get; set; }
/// <summary>
/// Legal entity identifier, populated after counterparty lookup and disambiguation.
/// Null until the user selects from candidate matches returned by the counterparty API.
/// </summary>
[JsonPropertyName("legal_entity")]
public string? LegalEntity { get; set; }
/// <summary>Murex trade identifier — each trade leg has a unique ID.</summary>
[JsonPropertyName("trade_id")]
public long TradeId { get; set; }
/// <summary>Display currency as ISO code (e.g., "GBP", "USD"). Derived from email currency symbols.</summary>
[JsonPropertyName("display_ccy")]
public string? DisplayCcy { get; set; }
/// <summary>Present value as a numeric amount, no formatting (no commas, no currency symbols).</summary>
[JsonPropertyName("pv")]
public double Pv { get; set; }
/// <summary>Break clause flag: "Y" or "N". Defaults to "N" if not mentioned in the email.</summary>
[JsonPropertyName("breakclause")]
public string? Breakclause { get; set; }
}
}

View File

@@ -1,24 +1,43 @@
// ValidationResult.cs -- Result of validating extracted fields.
// ValidationResult.cs -- Result of validating extracted fields or looking up data.
//
// Returned by the ExtractionPlugin's validation function so the AI agent
// can see which fields are missing or malformed and decide whether to
// retry extraction or escalate to the user.
// Returned by the ExtractionPlugin's tool functions so the AI agent can see
// whether validation passed, what errors were found, and whether disambiguation
// is needed (e.g., multiple counterparty/legal entity matches).
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Describes whether extracted fields passed validation, and if not,
/// which specific errors were found.
/// Describes whether a validation or lookup succeeded, any errors found,
/// and optional candidate matches for disambiguation.
/// </summary>
public class ValidationResult
{
/// <summary>True if all required fields are present and correctly typed.</summary>
/// <summary>True if validation passed or lookup returned a single match.</summary>
public bool IsValid { get; set; }
/// <summary>
/// List of validation error messages (e.g., "Missing required field: Client").
/// List of validation error messages (e.g., "Missing required field: counterparty").
/// Empty when IsValid is true.
/// </summary>
public List<string> Errors { get; set; } = new();
/// <summary>
/// Candidate matches for disambiguation. Non-null when a lookup returns
/// multiple possible matches and the user must select one.
/// The agent presents these to the user as a numbered list.
/// </summary>
public List<CandidateMatch>? Candidates { get; set; }
}
/// <summary>
/// A single candidate match returned by a lookup tool (e.g., counterparty search).
/// </summary>
public class CandidateMatch
{
/// <summary>The display name of the candidate (e.g., "Assured Guaranty UK Ltd").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>The legal entity identifier (e.g., "AG-UK-001").</summary>
public string LegalEntity { get; set; } = string.Empty;
}
}

View File

@@ -1,27 +1,96 @@
// ExtractionPluginTests.cs -- Tests for the extraction plugin's schema validation
// and external API tool methods.
//
// ValidateSchema is tested directly (no external APIs needed).
// LookupCounterparty, ValidateTrade, and ValidateCurrency are tested with
// mocked HttpClients using MockHttpMessageHandler to simulate API responses.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ChatAgent.Api.Plugins;
using ChatAgent.Api.Services;
using ChatAgent.Shared.Models;
using Moq;
using Moq.Protected;
namespace ChatAgent.Api.Tests;
public class ExtractionPluginTests
{
private readonly ExtractionPlugin _plugin = new();
// Helper to create an ExtractionPlugin with mocked HttpClients
private static ExtractionPlugin CreatePluginWithMocks(
HttpMessageHandler? counterpartyHandler = null,
HttpMessageHandler? tradeHandler = null,
HttpMessageHandler? currencyHandler = null)
{
var counterpartyClient = new CounterpartyApiClient(
new HttpClient(counterpartyHandler ?? CreateOkHandler("[]"))
{ BaseAddress = new Uri("http://test/") });
var tradeClient = new TradeApiClient(
new HttpClient(tradeHandler ?? CreateOkHandler("{\"isValid\":true}"))
{ BaseAddress = new Uri("http://test/") });
var currencyClient = new CurrencyApiClient(
new HttpClient(currencyHandler ?? CreateOkHandler("{\"isValid\":true}"))
{ BaseAddress = new Uri("http://test/") });
return new ExtractionPlugin(counterpartyClient, tradeClient, currencyClient);
}
// Creates an HttpMessageHandler that returns a 200 OK with the given JSON body
private static HttpMessageHandler CreateOkHandler(string jsonBody)
{
var mock = new Mock<HttpMessageHandler>();
mock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json")
});
return mock.Object;
}
// Creates an HttpMessageHandler that throws HttpRequestException
private static HttpMessageHandler CreateErrorHandler()
{
var mock = new Mock<HttpMessageHandler>();
mock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
return mock.Object;
}
// --- ValidateSchema tests ---
[Fact]
public void ValidateExtractedFields_AllRequiredPresent_ReturnsValid()
public void ValidateSchema_ValidResult_ReturnsValid()
{
var fields = new ExtractedFields
var plugin = CreatePluginWithMocks();
var extraction = new ExtractionResult
{
Client = "Acme Corp",
Project = "Phase 2",
Hours = 3,
Rate = 150,
Currency = "USD",
Date = "2026-04-01"
Items = new List<TradeItem>
{
new()
{
Valuedate = "27/11/2025",
Counterparty = "Assured Guaranty UK Limited",
TradeId = 79353083,
DisplayCcy = "GBP",
Pv = 4562456.0,
Breakclause = "N"
}
}
};
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
@@ -30,30 +99,66 @@ public class ExtractionPluginTests
}
[Fact]
public void ValidateExtractedFields_MissingRequired_ReturnsErrors()
public void ValidateSchema_MissingFields_ReturnsErrors()
{
// Missing Client and Hours
var fields = new ExtractedFields
var plugin = CreatePluginWithMocks();
// Missing counterparty and display_ccy
var extraction = new ExtractionResult
{
Project = "Phase 2",
Rate = 150,
Currency = "USD",
Date = "2026-04-01"
Items = new List<TradeItem>
{
new()
{
Valuedate = "27/11/2025",
TradeId = 79353083,
Pv = 4562456.0,
Breakclause = "N"
}
}
};
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Client"));
Assert.Contains(result.Errors, e => e.Contains("Hours"));
Assert.Contains(result.Errors, e => e.Contains("counterparty"));
Assert.Contains(result.Errors, e => e.Contains("display_ccy"));
}
[Fact]
public void ValidateExtractedFields_InvalidJson_ReturnsError()
public void ValidateSchema_InvalidBreakclause_ReturnsError()
{
var resultJson = _plugin.ValidateExtractedFields("not valid json");
var plugin = CreatePluginWithMocks();
var extraction = new ExtractionResult
{
Items = new List<TradeItem>
{
new()
{
Valuedate = "27/11/2025",
Counterparty = "Test Corp",
TradeId = 12345,
DisplayCcy = "GBP",
Pv = 100.0,
Breakclause = "Maybe"
}
}
};
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("'Y' or 'N'"));
}
[Fact]
public void ValidateSchema_InvalidJson_ReturnsError()
{
var plugin = CreatePluginWithMocks();
var resultJson = plugin.ValidateSchema("not valid json");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
@@ -62,45 +167,152 @@ public class ExtractionPluginTests
}
[Fact]
public void ValidateExtractedFields_ZeroHours_ReturnsError()
public void ValidateSchema_EmptyItems_ReturnsError()
{
var fields = new ExtractedFields
{
Client = "Acme Corp",
Project = "Phase 2",
Hours = 0,
Rate = 150,
Currency = "USD",
Date = "2026-04-01"
};
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
var plugin = CreatePluginWithMocks();
var resultJson = plugin.ValidateSchema("{\"items\":[]}");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Hours"));
Assert.Contains(result.Errors, e => e.Contains("no items"));
}
// --- LookupCounterparty tests ---
[Fact]
public async Task LookupCounterparty_SingleMatch_ReturnsValid()
{
var candidates = new List<CandidateMatch>
{
new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" }
};
var handler = CreateOkHandler(JsonSerializer.Serialize(candidates));
var plugin = CreatePluginWithMocks(counterpartyHandler: handler);
var resultJson = await plugin.LookupCounterparty("Assured Guaranty");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.True(result.IsValid);
Assert.NotNull(result.Candidates);
Assert.Single(result.Candidates);
}
[Fact]
public void ValidateExtractedFields_OptionalFieldsMissing_StillValid()
public async Task LookupCounterparty_MultipleMatches_ReturnsInvalidWithCandidates()
{
// Description and PoNumber are optional
var fields = new ExtractedFields
var candidates = new List<CandidateMatch>
{
Client = "Acme Corp",
Project = "Phase 2",
Hours = 3,
Rate = 150,
Currency = "USD",
Date = "2026-04-01"
// Description and PoNumber intentionally omitted
new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" },
new() { Name = "Assured Guaranty EU Ltd", LegalEntity = "AG-EU-002" }
};
var handler = CreateOkHandler(JsonSerializer.Serialize(candidates));
var plugin = CreatePluginWithMocks(counterpartyHandler: handler);
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
var resultJson = await plugin.LookupCounterparty("Assured Guaranty");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.NotNull(result.Candidates);
Assert.Equal(2, result.Candidates.Count);
}
[Fact]
public async Task LookupCounterparty_ApiError_ReturnsErrorMessage()
{
var plugin = CreatePluginWithMocks(counterpartyHandler: CreateErrorHandler());
var resultJson = await plugin.LookupCounterparty("Test");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Counterparty API unavailable"));
}
// --- ValidateTrade tests ---
[Fact]
public async Task ValidateTrade_ValidId_ReturnsValid()
{
var handler = CreateOkHandler("{\"isValid\":true}");
var plugin = CreatePluginWithMocks(tradeHandler: handler);
var resultJson = await plugin.ValidateTrade(79353083);
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.True(result.IsValid);
}
[Fact]
public async Task ValidateTrade_InvalidId_ReturnsError()
{
var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Trade not found\"}");
var plugin = CreatePluginWithMocks(tradeHandler: handler);
var resultJson = await plugin.ValidateTrade(99999);
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Trade not found"));
}
[Fact]
public async Task ValidateTrade_ApiError_ReturnsErrorMessage()
{
var plugin = CreatePluginWithMocks(tradeHandler: CreateErrorHandler());
var resultJson = await plugin.ValidateTrade(12345);
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Trade validation API unavailable"));
}
// --- ValidateCurrency tests ---
[Fact]
public async Task ValidateCurrency_ValidCode_ReturnsValid()
{
var handler = CreateOkHandler("{\"isValid\":true}");
var plugin = CreatePluginWithMocks(currencyHandler: handler);
var resultJson = await plugin.ValidateCurrency("GBP");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.True(result.IsValid);
}
[Fact]
public async Task ValidateCurrency_InvalidCode_ReturnsError()
{
var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Unknown currency\",\"suggestions\":[\"GBP\",\"GEL\"]}");
var plugin = CreatePluginWithMocks(currencyHandler: handler);
var resultJson = await plugin.ValidateCurrency("GBX");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("GBP"));
}
[Fact]
public async Task ValidateCurrency_ApiError_ReturnsErrorMessage()
{
var plugin = CreatePluginWithMocks(currencyHandler: CreateErrorHandler());
var resultJson = await plugin.ValidateCurrency("USD");
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
Assert.NotNull(result);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Currency validation API unavailable"));
}
}

View File

@@ -0,0 +1,75 @@
// FewShotServiceTests.cs -- Tests for the FewShotService that loads few-shot
// examples from disk and assembles ChatHistory prefixes.
using ChatAgent.Api.Services;
using ChatAgent.Shared.Models;
namespace ChatAgent.Api.Tests;
public class FewShotServiceTests
{
// Path to the examples folder from the test bin directory.
// WebApplicationFactory resolves content root to the API project,
// but for direct unit tests we resolve from the test assembly location.
private static string GetExamplesPath()
{
// Navigate from test bin/Debug/net9.0/ up to the repo root, then into examples/
var testDir = AppContext.BaseDirectory;
var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", ".."));
return Path.Combine(repoRoot, "examples", "extraction");
}
[Fact]
public void Constructor_LoadsInstructionTemplateAndExamples()
{
var service = new FewShotService(GetExamplesPath());
// Should have: 1 system message + 3 examples × 2 messages each = 7 messages
Assert.Equal(7, service.PrefixMessageCount);
}
[Fact]
public void CloneWithEmail_AppendsEmailAsUserMessage()
{
var service = new FewShotService(GetExamplesPath());
var history = service.CloneWithEmail("<html><body>Test email</body></html>");
// Prefix (7) + 1 email user message = 8
Assert.Equal(8, history.Count);
// Last message should be the email
Assert.Equal("<html><body>Test email</body></html>", history.Last().Content);
}
[Fact]
public void CloneWithEmailAndMessages_AppendsEmailAndFollowUps()
{
var service = new FewShotService(GetExamplesPath());
var followUp = new List<ChatMessage>
{
new() { Role = "assistant", Content = "Which counterparty?" },
new() { Role = "user", Content = "Option 1" }
};
var history = service.CloneWithEmailAndMessages("<html>email</html>", followUp);
// Prefix (7) + 1 email + 2 follow-up = 10
Assert.Equal(10, history.Count);
}
[Fact]
public void CloneWithEmail_DoesNotMutatePrefix()
{
var service = new FewShotService(GetExamplesPath());
// Clone twice — second clone should not include the first email
var history1 = service.CloneWithEmail("email 1");
var history2 = service.CloneWithEmail("email 2");
Assert.Equal(8, history1.Count);
Assert.Equal(8, history2.Count);
Assert.Equal("email 1", history1.Last().Content);
Assert.Equal("email 2", history2.Last().Content);
}
}