Compare commits

9 Commits

Author SHA1 Message Date
local
956ec243c5 fix: update export bundle for Azure OpenAI and add streaming diagnostics
Replace CLIProxyAPI/local proxy references with Azure OpenAI using
DefaultAzureCredential and tenant ID auth. Add Critical Pattern #8
for SSE buffering diagnostics with timestamped curl test. Add
streaming verification tasks (T6b, T15) and troubleshooting entries
for Azure AD auth, RBAC, response compression, and proxy buffering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:42:38 +01:00
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
local
7a5c22593a feat: enable rich text display for assistant messages
Add markdown-to-HTML rendering for assistant messages using Markdig with
HTML sanitization. Includes cached rendering to avoid lag during streaming,
styled markdown elements (code blocks, tables, lists, blockquotes) within
chat bubbles, and 18 unit tests covering rendering and XSS prevention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:29:58 +01:00
local
d3300c7db9 feat: add extract-feature and export-spec portability skills
Two new OpenSpec skills for porting features to sandboxed codebases:
- /opsx:extract-feature generates minimal, printable code recipes
- /opsx:export-spec generates compact specs for AI-assisted reimplementation
Both support cumulative dependency analysis across archived changes.

Includes first export of migrate-to-semantic-kernel in all three formats:
code recipe (~120 lines), portable spec (~40 lines), OpenSpec variant (~25 lines).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:59:06 +01:00
local
471e9ce935 feat: migrate chat backend to Semantic Kernel with tool calling support
Replace manual HTTP proxy in ChatController with Semantic Kernel's
OpenAI chat completion service pointed at CLIProxyAPI. Add extraction
plugin with validation function for structured field extraction from
natural language, enabling an agentic loop with auto-retry and
human-in-the-loop escalation.

- Add Microsoft.SemanticKernel 1.74.0 with OpenAI connector
- Create ExtractedFields schema and ValidationResult models
- Create ExtractionPlugin with [KernelFunction] validation
- Rewrite ChatController to use IChatCompletionService streaming
- Configure FunctionChoiceBehavior.Auto() for tool calling
- Preserve existing SSE contract (client unchanged)
- Update tests to mock SK services, add plugin and integration tests
- Archive multi-turn-conversations and migrate-to-semantic-kernel changes
- Sync specs for agent-extraction, semantic-kernel-integration, chat-streaming

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:59:13 +01:00
local
3278a408b9 feat: add multi-turn conversation support within a session
Send full conversation history with each API request so the AI maintains
context across exchanges. Add "New Chat" button to clear the conversation
and start fresh. No persistent storage — session resets on page reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:40:33 +01:00
local
17a5a58e73 test: add xUnit test coverage for API controllers and client services
Create ChatAgent.Api.Tests and ChatAgent.Client.Tests projects with xUnit
and Moq. Test HealthController (200 + valid response), ChatController
(SSE streaming with mocked upstream, error handling), and ChatApiClient
(delta parsing, error events, health endpoint). 6 tests, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:21:10 +01:00
local
00e7df2802 feat: wire chat UI to Responses API with streaming
Add ChatController that proxies POST /api/chat to the local Responses API
(localhost:8317/v1/responses) with SSE streaming. Client reads tokens via
SetBrowserResponseStreamingEnabled and renders them incrementally. Includes
thinking indicator, input disabled during streaming, and error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:54:28 +01:00
136 changed files with 9716 additions and 138 deletions

View File

@@ -0,0 +1,227 @@
---
name: "OPSX: Export Spec"
description: Export a feature as a compact, portable spec for AI-assisted reimplementation on a sandboxed machine
category: Workflow
tags: [workflow, portability, experimental]
---
Export a feature as a portable spec for AI-assisted reimplementation.
Instead of retyping code, you retype a compact spec. The AI on the sandbox generates the code.
---
**Input**: The argument after `/opsx:export-spec` is a change name (active or archived), or a description of the feature. If omitted, prompt for selection.
**Steps**
1. **Identify the source feature**
Same selection logic as `/opsx:extract-feature`:
- Check active changes and archive for the change name
- If not found, prompt with **AskUserQuestion tool**
- Read all artifacts: `proposal.md`, `design.md`, `tasks.md`, specs
2. **Analyze dependency chain (cumulative mode)**
Features often build on each other. Before generating the spec, determine
what the target feature depends on.
a. Read all archived change proposals in `openspec/changes/archive/` (sorted by date).
b. Build a dependency graph: what does the target require? What's superseded?
c. Ask the user:
> "This feature depends on earlier changes. How should I scope the spec?"
> 1. **Cumulative** — include all foundation (recommended for fresh codebase)
> 2. **Delta only** — just this change (target already has foundation)
> 3. **Custom** — pick which dependencies to include
d. In cumulative mode: merge into one coherent spec, skip superseded components.
e. In delta mode: add an "Assumes" section listing what must already exist.
3. **Read the actual implementation**
Read all source files created or modified by this feature (and dependencies if cumulative).
The spec must reflect what was actually built, not just what was planned.
4. **Determine the target context**
Use **AskUserQuestion tool** to ask:
> "Tell me about the target codebase:
> 1. Project name / root namespace
> 2. Existing stack (ASP.NET Core? Blazor? MudBlazor?)
> 3. Does it already have any of these? (controllers, DI setup, chat endpoint)
> 4. Does the target have OpenSpec? GitHub Copilot? Claude Code?"
This shapes what the spec assumes vs what it must specify.
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
- **Precise**: Unambiguous enough for an AI to implement correctly
- **Self-contained**: No references to external files or repos
- **Stack-aware**: Uses the right terminology for the target stack
Structure:
```markdown
# 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>
## Architecture
<2-3 sentence overview>
## Components
### <Component>: <path hint>
- <What it does>
- <Key behavior>
- <Interface/contract>
## 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>
## Behavior
<Non-obvious requirements>
```
**Compression strategies:**
- Use bullet points, not prose
- Specify contracts precisely (field names, types, API shapes)
- Let the AI infer standard patterns
- Only specify non-obvious behavior
- Omit anything the AI would do by default
7. **Estimate typing effort**
Count characters in the spec. Compare to the code recipe equivalent.
Show the compression ratio.
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`
9. **Write the output**
Save to: `openspec/exports/<change-name>-spec.md`
Display the full content for review.
**Guardrails**
- Prioritize precision over brevity — ambiguity wastes more time than length
- Always include exact field names, types, and API shapes
- Include non-obvious gotchas (like /v1 base URL requirements)
- Mental test: could an AI implement this correctly without seeing the original code?
- If too complex for ~50 lines, split into multiple specs by component
- Always show the compression ratio
- Must be readable when printed in monospace — no wide tables or long lines
- In cumulative mode, the spec must read as one coherent feature, not a list of changes
- Skip superseded components — always describe the latest version
- 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

@@ -0,0 +1,114 @@
---
name: "OPSX: Extract Feature"
description: Extract a feature into a minimal, printable code recipe for manual reimplementation
category: Workflow
tags: [workflow, portability, experimental]
---
Extract a feature into a minimal, printable code recipe for manual reimplementation.
Generates a markdown document with:
- Package dependencies
- Ordered code blocks (no comments, no boilerplate)
- Clear markers for generic vs domain-specific code
Print it, take it to the sandbox, type it in.
---
**Input**: The argument after `/opsx:extract-feature` is a change name (active or archived), a git commit range, or a list of files. If omitted, prompt for selection.
**Steps**
1. **Identify the source feature**
If a change name is provided:
- Check active changes: `openspec list --json`
- Check archive: look in `openspec/changes/archive/` for directories ending with the name
- If found, read its artifacts: `proposal.md`, `design.md`, `tasks.md`
If no name provided:
- Run `openspec list --json` and list archived changes
- Use **AskUserQuestion tool** to let the user select
If a file list or commit range is provided instead:
- Read those files directly
- Identify the feature from the code
2. **Analyze dependency chain (cumulative mode)**
Features often build on each other. Before generating the recipe, determine
what the target feature depends on.
a. Read all archived change proposals in `openspec/changes/archive/` (sorted by date).
b. Build a dependency graph: what does the target require? What's superseded?
c. Ask the user:
> "This feature depends on earlier changes. How should I scope the recipe?"
> 1. **Cumulative** — include all foundation code (recommended for fresh codebase)
> 2. **Delta only** — just this change's code (target already has foundation)
> 3. **Custom** — pick which dependencies to include
d. In cumulative mode: merge the chain, skip superseded code, use final file versions.
e. In delta mode: include only the selected change's code.
3. **Analyze the feature scope**
From the change artifacts and/or code (across full dependency chain if cumulative), determine:
- Which files were created or modified
- What NuGet/npm packages were added
- What the dependency order is (e.g., models before controllers)
- What is generic infrastructure vs domain-specific logic
Read all relevant source files. Always read the final current version of each file.
4. **Generate the code recipe**
Create a markdown document with these sections, in this order:
**Header**: Feature name, source change, date
**Prerequisites**: Package references with exact versions
**Steps**: Ordered by dependency. Each step contains:
- Step number and action (e.g., "New file", "Add to existing file", "Modify")
- Target path (relative, adaptable)
- Namespace placeholder: `__YOUR_NAMESPACE__` where the target project namespace goes
- **Code block**: The actual code, stripped of:
- All comments (// and /* */ and /// XML docs)
- Redundant blank lines
- Verbose variable names that can be shortened
- Any code not directly related to the feature
- If the step modifies an existing file: show only the code to add, with a brief marker for insertion point (e.g., "Add after AddControllers()")
**Domain-Specific Sections**: Clearly marked with `// ADAPT:` prefix explaining what to change for the target domain
5. **Optimize for retyping**
Review the generated document and:
- Merge small files if they can be combined
- Remove any using statements that the IDE will auto-add
- Shorten any unnecessarily verbose code
- Ensure no step exceeds ~40 lines (split if needed)
- Add line counts per step so the user can estimate effort
- Total the overall line count at the top
6. **Write the output**
Save to: `openspec/exports/<change-name>-recipe.md`
Also display the full content so the user can review it immediately.
**Guardrails**
- Never include comments in code blocks — the goal is minimum keystrokes
- Always read the actual current source files, not just the change artifacts
- Preserve compilation order: models -> services -> controllers -> DI registration
- 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
- If a dependency chain is long (4+ changes), suggest `/opsx:export-spec` as more efficient
ARGUMENTS: based on the above

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,217 @@
---
name: openspec-export-spec
description: Export a feature as a compact, portable spec that an AI agent (Copilot, Claude) on a sandboxed machine can implement from. Optimized for minimal hand-typing.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Export a feature as a portable spec for AI-assisted reimplementation on a sandboxed machine.
Instead of retyping code, you retype a compact spec. The AI on the sandbox (Copilot, Claude, OpenSpec) generates the code from the spec.
---
**Input**: A change name (active or archived), or a description of the feature. If omitted, prompt for selection.
**Steps**
1. **Identify the source feature**
Same as `openspec-extract-feature` step 1:
- Check active changes and archive for the change name
- If not found, prompt with **AskUserQuestion tool**
- Read all artifacts: `proposal.md`, `design.md`, `tasks.md`, specs
2. **Analyze dependency chain (cumulative mode)**
Features often build on each other. Before generating the spec, determine
what the target feature depends on.
a. **Read all archived change proposals** in `openspec/changes/archive/` (sorted by date).
For each, read `proposal.md` to understand what it adds and what it depends on.
b. **Build a dependency graph**:
- Which changes does the target feature require?
- Which earlier changes are superseded? (e.g., if Change 6 replaces Change 3's
implementation, include Change 6's version, not Change 3's)
- Which changes are unrelated and can be skipped?
c. **Ask the user** using **AskUserQuestion tool**:
> "This feature depends on earlier changes. How should I scope the spec?"
>
> Options:
> 1. **Cumulative** — include all foundation this feature needs (recommended if target is a fresh codebase)
> 2. **Delta only** — just what this specific change adds (use if target already has the foundation)
> 3. **Custom** — let me pick which dependencies to include
Show the dependency chain so the user can decide.
d. **In cumulative mode**: The spec covers the entire stack from foundation to feature.
- Merge packages, components, wiring from all included changes
- Skip superseded components (use the latest version)
- The spec should read as a single coherent feature, not a list of changes
e. **In delta mode**: The spec covers only the selected change.
- Add a "Assumes" section listing what must already exist in the target
3. **Read the actual implementation**
Read all source files that were created or modified by this feature
(and its dependencies if cumulative).
The spec must reflect what was actually built, not just what was planned.
4. **Determine the target context**
Use **AskUserQuestion tool** to ask:
> "Tell me about the target codebase:
> 1. Project name / root namespace
> 2. Existing stack (ASP.NET Core? Blazor? MudBlazor?)
> 3. Does it already have any of these? (controllers, DI setup, chat endpoint)
> 4. Does the target have OpenSpec? GitHub Copilot? Claude Code?"
This shapes what the spec assumes vs what it must specify.
5. **Generate the portable spec**
Create a single markdown document that is:
- **Compact**: Target ~30-50 lines for a medium feature
- **Precise**: Unambiguous enough for an AI to implement correctly
- **Self-contained**: No references to external files or repos
- **Stack-aware**: Uses the right terminology for the target stack
Structure:
```markdown
# Feature: <Name>
## Target: <project name> (<stack>)
## Packages
<list with versions>
## Architecture
<2-3 sentence overview of how the pieces fit together>
## Components
### <Component 1>: <path hint>
- <What it does — 1 line>
- <Key behavior — 1 line>
- <Interface/contract — 1 line>
### <Component 2>: <path hint>
...
## Contracts
<API shapes, model definitions, SSE formats — the things that MUST be exact>
## Wiring
<DI registration, middleware order, configuration keys — in dependency order>
## Behavior
<Key behavioral requirements that aren't obvious from the structure>
```
**Compression strategies:**
- Use bullet points, not prose
- Specify contracts precisely (field names, types, API shapes)
- Let the AI infer standard patterns (error handling, null checks, etc.)
- Only specify non-obvious behavior (the surprising parts)
- Omit anything the AI would do by default for the given stack
6. **Estimate typing effort**
Count characters in the spec. Compare to the code recipe equivalent.
Show the compression ratio:
```
Code recipe: ~120 lines to type
This spec: ~35 lines to type
Compression: 3.4x
```
7. **Optionally generate an OpenSpec-compatible version**
If the target has OpenSpec, also generate a version structured as:
- A `proposal.md` (minimal — 5-10 lines)
- A `tasks.md` (implementation steps the target AI follows)
These can be even more compact because OpenSpec provides the scaffolding.
Save this variant alongside the main spec.
8. **Write the output**
Save to: `openspec/exports/<change-name>-spec.md`
If OpenSpec variant: `openspec/exports/<change-name>-openspec.md`
Display the full content for review.
**Output Format**
```markdown
# Feature: Semantic Kernel Chat with Tool Calling
## Target: ApplicationX (ASP.NET Core + Blazor WASM + MudBlazor)
## Packages
- Microsoft.SemanticKernel 1.74.0
- Microsoft.SemanticKernel.Connectors.OpenAI 1.74.0
## Architecture
POST /api/chat endpoint accepts messages, runs them through SK's chat completion
with auto tool calling enabled, streams response as SSE. An ExtractionPlugin
validates structured data extracted by the LLM.
## Components
### ChatController: Controllers/ChatController.cs
- POST endpoint, injects Kernel via DI
- Converts ChatMessage[] to SK ChatHistory
- Streams via GetStreamingChatMessageContentsAsync
- Outputs SSE: `data: {"text":"..."}\n\n` then `data: [DONE]\n\n`
### ExtractionPlugin: Plugins/ExtractionPlugin.cs
- [KernelFunction("validate_extracted_fields")]
- Accepts JSON string, deserializes to ExtractedFields
- Returns {"isValid": bool, "errors": string[]}
### ExtractedFields: Models/ExtractedFields.cs
- Required: Client(string), Project(string), Hours(decimal), Rate(decimal), Currency(string), Date(string)
- Optional: Description(string), PoNumber(string)
### ValidationResult: Models/ValidationResult.cs
- IsValid(bool), Errors(List<string>)
### ChatRequest/ChatMessage: Shared/Models/
- ChatRequest: Messages(List<ChatMessage>)
- ChatMessage: Role(string), Content(string)
## Wiring (Program.cs, add after AddControllers)
- AddOpenAIChatCompletion(model, endpoint with /v1 suffix, apiKey)
- AddKernel()
- AddSingleton<ExtractionPlugin>()
- UseCors for Blazor client origin
## Behavior
- FunctionChoiceBehavior.Auto() enables autonomous tool calling
- SK's built-in limit prevents runaway tool call loops
- Plugin imported per-request via _kernel.ImportPluginFromObject
- Base URL must include /v1 — OpenAI SDK appends chat/completions directly
```
**Lines to type: ~35 | Code equivalent: ~150 lines | Compression: 4.3x**
**Guardrails**
- Prioritize precision over brevity — an ambiguous spec wastes more time than a slightly longer one
- Always include exact field names, types, and API shapes — these are the hardest to guess
- Include non-obvious gotchas (like the /v1 base URL requirement)
- Test the spec mentally: could an AI implement this correctly without seeing the original code?
- If the feature is too complex for a single spec page (~50+ lines), split into multiple specs by component
- Always show the compression ratio so the user can decide between spec and code recipe
- The spec must be readable when printed in monospace — no wide tables or long lines
- In cumulative mode, the spec must read as one coherent feature — not a list of sequential changes
- Skip superseded components — always describe the latest version of each piece
- In delta mode, add an "Assumes" section so the target AI knows what must already exist
- In the output header, note which changes were included and which were skipped

View File

@@ -0,0 +1,170 @@
---
name: openspec-extract-feature
description: Extract a feature from a change (archived or active) into a minimal, printable code recipe optimized for manual retyping into a sandboxed codebase.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Extract a feature into a minimal, printable code recipe for manual reimplementation.
Generates a markdown document with:
- Package dependencies
- Ordered code blocks (no comments, no boilerplate)
- Clear markers for generic vs domain-specific code
Print it, take it to the sandbox, type it in.
---
**Input**: A change name (active or archived), a git commit range, or a list of files. If omitted, prompt for selection.
**Steps**
1. **Identify the source feature**
If a change name is provided:
- Check active changes: `openspec list --json`
- Check archive: look in `openspec/changes/archive/` for directories ending with the name
- If found, read its artifacts: `proposal.md`, `design.md`, `tasks.md`
If no name provided:
- Run `openspec list --json` and list archived changes
- Use **AskUserQuestion tool** to let the user select
If a file list or commit range is provided instead:
- Read those files directly
- Identify the feature from the code
2. **Analyze dependency chain (cumulative mode)**
Features are often not independent — they build on each other. Before generating
the recipe, determine what the target feature depends on.
a. **Read all archived change proposals** in `openspec/changes/archive/` (sorted by date).
For each, read `proposal.md` to understand what it adds and what it depends on.
b. **Build a dependency graph**:
- Which changes does the target feature require?
- Which earlier changes are superseded? (e.g., if Change 6 replaces Change 3's
implementation, include Change 6's version, not Change 3's)
- Which changes are unrelated and can be skipped?
c. **Ask the user** using **AskUserQuestion tool**:
> "This feature depends on earlier changes. How should I scope the recipe?"
>
> Options:
> 1. **Cumulative** — include all foundation code this feature needs (recommended if target is a fresh codebase)
> 2. **Delta only** — just the code this specific change adds (use if target already has the foundation)
> 3. **Custom** — let me pick which dependencies to include
Show the dependency chain so the user can decide, e.g.:
```
migrate-to-semantic-kernel depends on:
← basic-chat-interface (UI + shared models)
← wire-responses-api (superseded — SK replaces the proxy)
← multi-turn-conversations (chat history)
```
d. **In cumulative mode**: Merge the dependency chain into a single coherent recipe.
- Walk changes in dependency order
- Skip superseded code (use the latest version of each file)
- Collapse packages from all changes into one prerequisites section
- If a file was created in Change 2 and modified in Change 5, include only the final version
e. **In delta mode**: Include only the code from the selected change.
3. **Analyze the feature scope**
From the change artifacts and/or code, determine:
- Which files were created or modified (across the full dependency chain if cumulative)
- What NuGet/npm packages were added
- What the dependency order is (e.g., models before controllers)
- What is generic infrastructure vs domain-specific logic
Read all relevant source files in the current codebase to get the actual current code.
**Always read the final current version of each file**, not intermediate versions.
3. **Generate the code recipe**
Create a markdown document with these sections, in this order:
**Header**: Feature name, source change, date
**Prerequisites**: Package references with exact versions
**Steps**: Ordered by dependency. Each step contains:
- Step number and action (e.g., "New file", "Add to existing file", "Modify")
- Target path (relative, adaptable)
- Namespace placeholder: `__YOUR_NAMESPACE__` where the target project namespace goes
- **Code block**: The actual code, stripped of:
- All comments (// and /* */ and /// XML docs)
- Redundant blank lines
- Verbose variable names that can be shortened
- Any code not directly related to the feature
- If the step modifies an existing file: show only the code to add, with a brief marker for insertion point (e.g., "Add after AddControllers()")
**Domain-Specific Sections**: Clearly marked with `// ADAPT:` prefix explaining what to change for the target domain
4. **Optimize for retyping**
Review the generated document and:
- Merge small files if they can be combined
- Remove any using statements that the IDE will auto-add
- Shorten any unnecessarily verbose code
- Ensure no step exceeds ~40 lines (split if needed)
- Add line counts per step so the user can estimate effort
- Total the overall line count at the top
5. **Write the output**
Save to: `openspec/exports/<change-name>-recipe.md`
Also display the full content so the user can review it immediately.
**Output Format**
```markdown
# Feature Recipe: <Feature Name>
**Source**: <change-name> | **Lines to type**: ~<N>
## Prerequisites
- `Microsoft.SemanticKernel` 1.74.0
- `Microsoft.SemanticKernel.Connectors.OpenAI` 1.74.0
## Step 1: <Action> — <path> (~N lines)
```csharp
<stripped code>
```
## Step 2: <Action> — <path> (~N lines)
Insert after `<marker>`:
```csharp
<stripped code>
```
## Domain-Specific (adapt these)
### ExtractionPlugin.cs
```csharp
// ADAPT: Replace field names and validation logic for your domain
<code>
```
```
**Guardrails**
- Never include comments in code blocks — the goal is minimum keystrokes
- Always read the actual current source files, not just the change artifacts
- Preserve compilation order: models → services → controllers → DI registration
- 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
- 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 (with reason)
- If a dependency chain is long (4+ changes), suggest `/opsx:export-spec` as a more efficient alternative

View File

@@ -11,6 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Api", "src\ChatAg
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Shared", "src\ChatAgent.Shared\ChatAgent.Shared.csproj", "{06182E3F-BC78-449B-ADF6-D9EE49E48945}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Shared", "src\ChatAgent.Shared\ChatAgent.Shared.csproj", "{06182E3F-BC78-449B-ADF6-D9EE49E48945}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Api.Tests", "tests\ChatAgent.Api.Tests\ChatAgent.Api.Tests.csproj", "{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Client.Tests", "tests\ChatAgent.Client.Tests\ChatAgent.Client.Tests.csproj", "{DBB73B66-042A-4858-B813-23AEA84FC4C6}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -57,6 +63,30 @@ Global
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x64.Build.0 = Release|Any CPU {06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x64.Build.0 = Release|Any CPU
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.ActiveCfg = Release|Any CPU {06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.ActiveCfg = Release|Any CPU
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.Build.0 = Release|Any CPU {06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.Build.0 = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x64.ActiveCfg = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x64.Build.0 = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x86.ActiveCfg = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x86.Build.0 = Debug|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|Any CPU.Build.0 = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x64.ActiveCfg = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x64.Build.0 = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x86.ActiveCfg = Release|Any CPU
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x86.Build.0 = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x64.ActiveCfg = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x64.Build.0 = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x86.ActiveCfg = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x86.Build.0 = Debug|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|Any CPU.Build.0 = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x64.ActiveCfg = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x64.Build.0 = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x86.ActiveCfg = Release|Any CPU
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -65,5 +95,7 @@ Global
{600EA0C4-7CDE-4807-BE3C-30A6D2242392} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {600EA0C4-7CDE-4807-BE3C-30A6D2242392} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{467D4550-6F9A-456E-B99C-0ABE94070ECF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {467D4550-6F9A-456E-B99C-0ABE94070ECF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{06182E3F-BC78-449B-ADF6-D9EE49E48945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {06182E3F-BC78-449B-ADF6-D9EE49E48945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{DBB73B66-042A-4858-B813-23AEA84FC4C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

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,45 @@
## Context
No test projects exist. The solution has 3 projects (Client, Api, Shared) under `src/`. Tests need to cover the API controllers and the client's ChatApiClient service, both of which involve HTTP and SSE streaming.
## Goals / Non-Goals
**Goals:**
- Establish test infrastructure (xUnit + Moq)
- Test API controllers using WebApplicationFactory (integration-style)
- Test ChatApiClient using a mock HttpMessageHandler (unit-style)
- All tests runnable via `dotnet test` from solution root
**Non-Goals:**
- Blazor component tests (bUnit) — Chat.razor is UI-heavy, defer to a future phase
- End-to-end browser tests (Playwright/Selenium)
- Testing the upstream Responses API itself
## Decisions
### Decision 1: Test framework — xUnit + Moq
xUnit is the .NET standard. Moq for mocking HttpMessageHandler so we can control HTTP responses in tests without hitting real servers.
### Decision 2: Test project layout
```
tests/
├── ChatAgent.Api.Tests/ → references Api project
└── ChatAgent.Client.Tests/ → references Client project + Shared
```
Both added to the `ChatAgent.sln` solution file under a `tests` solution folder.
### Decision 3: API tests use WebApplicationFactory
`Microsoft.AspNetCore.Mvc.Testing` provides `WebApplicationFactory<Program>` for integration-style tests. For ChatController, we inject a mock `IHttpClientFactory` that returns a handler with canned SSE responses — no real Responses API needed.
### Decision 4: Client tests mock HttpMessageHandler
ChatApiClient takes an HttpClient. Tests create an HttpClient with a custom `DelegatingHandler` that returns canned SSE response streams. This tests the SSE parsing logic in isolation.
## Risks / Trade-offs
- [WebApplicationFactory requires Api's Program to be accessible] → Add `InternalsVisibleTo` or use the `public partial class Program {}` trick in Api's Program.cs
- [SSE stream mocking is verbose] → Create a small helper method that builds SSE response content from a list of events

View File

@@ -0,0 +1,26 @@
## Why
The project has zero test coverage. There are two controllers, a typed HttpClient service, and shared models — all untested. Adding tests now establishes the pattern before the codebase grows, and catches regressions as new phases are added.
## What Changes
- Create an xUnit test project for the API (`ChatAgent.Api.Tests`)
- Create an xUnit test project for the Client services (`ChatAgent.Client.Tests`)
- Add tests for HealthController (GET /api/health)
- Add tests for ChatController (POST /api/chat SSE streaming proxy)
- Add tests for ChatApiClient (GetHealthAsync, SendChatStreamingAsync)
- Add both test projects to the solution
## Capabilities
### New Capabilities
- `test-infrastructure`: Test project setup, test framework choices, and shared test utilities
### Modified Capabilities
<!-- None — adding tests doesn't change existing specs -->
## Impact
- `tests/ChatAgent.Api.Tests/`: New xUnit test project
- `tests/ChatAgent.Client.Tests/`: New xUnit test project
- `ChatAgent.sln`: Add test projects

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: API test project exists
An xUnit test project SHALL exist at `tests/ChatAgent.Api.Tests/` targeting the API project, with xUnit, Moq, and Microsoft.AspNetCore.Mvc.Testing as dependencies.
#### Scenario: API tests run
- **WHEN** `dotnet test` is run from the solution root
- **THEN** API tests are discovered and executed
### Requirement: Client test project exists
An xUnit test project SHALL exist at `tests/ChatAgent.Client.Tests/` targeting the Client services, with xUnit and Moq as dependencies.
#### Scenario: Client tests run
- **WHEN** `dotnet test` is run from the solution root
- **THEN** Client service tests are discovered and executed
### Requirement: HealthController test coverage
Tests SHALL verify that GET /api/health returns HTTP 200 with a valid HealthResponse containing a non-empty Status and a recent Timestamp.
#### Scenario: Health endpoint returns 200
- **WHEN** a GET request is sent to /api/health
- **THEN** the response status is 200 and the body contains `status: "healthy"` and a UTC timestamp
### Requirement: ChatController test coverage
Tests SHALL verify that POST /api/chat returns a streaming SSE response containing text deltas and a [DONE] terminator. Tests SHALL mock the upstream Responses API.
#### Scenario: Chat streams text deltas
- **WHEN** a POST is sent to /api/chat with a valid ChatRequest
- **THEN** the response is `text/event-stream` containing `data: {"text":"..."}` events followed by `data: [DONE]`
#### Scenario: Chat handles upstream error
- **WHEN** the Responses API is unreachable or returns an error
- **THEN** the response contains a `data: {"error":"..."}` event followed by `data: [DONE]`
### Requirement: ChatApiClient test coverage
Tests SHALL verify that SendChatStreamingAsync correctly parses SSE events from the backend into text deltas, handles [DONE], and throws on error events.
#### Scenario: Client parses text deltas
- **WHEN** the backend returns SSE events with text deltas
- **THEN** SendChatStreamingAsync yields each text fragment in order
#### Scenario: Client handles error event
- **WHEN** the backend returns an error SSE event
- **THEN** SendChatStreamingAsync throws HttpRequestException with the error message

View File

@@ -0,0 +1,23 @@
## 1. Test Project Setup
- [x] 1.1 Create xUnit test project at tests/ChatAgent.Api.Tests with xUnit, Moq, Microsoft.AspNetCore.Mvc.Testing
- [x] 1.2 Create xUnit test project at tests/ChatAgent.Client.Tests with xUnit, Moq
- [x] 1.3 Add both test projects to ChatAgent.sln under a tests solution folder
- [x] 1.4 Make Api's Program class accessible to tests (public partial class Program)
## 2. API Tests
- [x] 2.1 Test HealthController: GET /api/health returns 200 with valid HealthResponse
- [x] 2.2 Test ChatController: POST /api/chat streams SSE text deltas from mocked upstream
- [x] 2.3 Test ChatController: POST /api/chat handles upstream error gracefully
## 3. Client Service Tests
- [x] 3.1 Create SSE response helper for building mock SSE streams
- [x] 3.2 Test ChatApiClient.SendChatStreamingAsync: parses text deltas in order
- [x] 3.3 Test ChatApiClient.SendChatStreamingAsync: throws on error event
- [x] 3.4 Test ChatApiClient.GetHealthAsync: returns HealthResponse on success
## 4. Verify
- [x] 4.1 Run dotnet test from solution root — all tests pass

View File

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

View File

@@ -0,0 +1,77 @@
## Context
The chat backend currently proxies requests to a local CLIProxyAPI instance (OpenAI-compatible API at `localhost:8317`) via manual `HttpClient` calls and SSE parsing in `ChatController`. The architecture works for simple chat completion but has no abstraction for tool calling, function invocation, or agentic loops. The goal is to adopt Semantic Kernel as the AI orchestration layer to enable structured extraction with autonomous validation.
## Goals / Non-Goals
**Goals:**
- Replace manual HTTP proxy logic with Semantic Kernel's chat completion service
- Enable tool/function calling via SK plugins
- Implement an agentic extraction loop: extract → validate → retry (up to 3 times) → escalate to user
- Preserve the existing SSE contract so the Blazor client requires no changes
- Maintain inline tutorial comments explaining SK concepts
**Non-Goals:**
- Multi-agent orchestration (future — when Agent Framework reaches GA)
- Changing the Blazor client or `ChatApiClient`
- Adding new UI for structured output display (future change)
- Replacing CLIProxyAPI — SK's OpenAI connector talks to it as-is
- Authentication or multi-user support
## Decisions
### D1: Use SK's OpenAI chat completion connector pointed at CLIProxyAPI
**Choice:** `Microsoft.SemanticKernel.Connectors.OpenAI` with `OpenAIChatCompletionService` configured to use `localhost:8317` as the endpoint.
**Alternatives considered:**
- SK Anthropic connector (talks to Anthropic API directly) — would bypass CLIProxyAPI and lose model-switching flexibility
- Keep manual HttpClient alongside SK — defeats the purpose of the migration
**Rationale:** CLIProxyAPI already provides an OpenAI-compatible interface. SK's OpenAI connector works with any OpenAI-compatible endpoint. No infrastructure change required.
### D2: Register Kernel and plugins in DI via `Program.cs`
**Choice:** Configure `Kernel` in `Program.cs` using `builder.Services.AddKernel()` and register plugins via DI. Inject `Kernel` into `ChatController`.
**Rationale:** Follows ASP.NET Core conventions. The kernel is a singleton service with plugins registered at startup. Controller receives it via constructor injection, consistent with the existing pattern of injecting `IHttpClientFactory` and `IConfiguration`.
### D3: Validation as a native SK plugin function
**Choice:** Create an `ExtractionPlugin` class with `[KernelFunction]` methods: one for validation of extracted fields. The agent auto-invokes this via `ToolCallBehavior.AutoInvokeKernelFunctions`.
**Alternatives considered:**
- Manual tool call loop in controller code — loses SK's built-in retry/function-calling orchestration
- Separate validation service outside SK — requires manual plumbing between LLM and validator
**Rationale:** SK's auto-invocation handles the loop naturally. The LLM sees the validation function as a tool, calls it, reads the result, and decides whether to retry or escalate. This is the core value proposition of adopting SK.
### D4: Iteration cap with human-in-the-loop escalation
**Choice:** Configure `ToolCallBehavior.AutoInvokeKernelFunctions` with `MaximumAutoInvokeAttempts = 3`. If the agent exhausts retries without valid output, it returns a clarification request as a regular chat message to the user.
**Rationale:** The iteration cap prevents runaway loops. The escalation path uses the existing chat UI — the agent simply asks for clarification in natural language, and the user responds in the next message. No special UI needed.
### D5: Preserve SSE contract via streaming kernel invocation
**Choice:** Use `kernel.InvokeStreamingAsync<StreamingChatMessageContent>()` (or `IChatCompletionService.GetStreamingChatMessageContentsAsync()`) and re-emit tokens as the same SSE format the client expects: `data: {"text":"..."}\n\n` and `data: [DONE]\n\n`.
**Rationale:** The Blazor client's `ChatApiClient` parses this exact format. By keeping the SSE contract identical, the entire client codebase remains untouched.
### D6: Predefined extraction schema as a strongly-typed C# class
**Choice:** Define an `ExtractedFields` record/class in `ChatAgent.Shared.Models` with the fixed set of known fields. Validation logic checks for required fields and type correctness.
**Rationale:** Single output type with fixed keys. A strongly-typed class gives compile-time safety, works with `System.Text.Json` serialization, and can carry data annotations for validation rules.
## Risks / Trade-offs
- **[SK OpenAI connector compatibility with CLIProxyAPI]** → CLIProxyAPI aims for OpenAI API parity but may have edge cases with tool calling responses. Mitigation: test tool calling end-to-end early; fall back to direct Anthropic connector if needed.
- **[Streaming + tool calling interaction]** → When the agent calls a tool mid-stream, the streaming behavior may differ from pure chat completion. Mitigation: handle tool call chunks in the SSE bridge; may need to buffer during tool execution and resume streaming after.
- **[SK version churn]** → Semantic Kernel is actively developed; APIs may evolve. Mitigation: pin to a specific stable version, document the version in stack spec.
- **[Tutorial complexity increase]** → SK adds abstractions (kernel, plugins, functions) that need explaining. Mitigation: maintain inline comments for every SK concept, consistent with project convention.
## Open Questions
- What are the exact field names and types for `ExtractedFields`? (Need user input for the real schema — can use a placeholder for initial implementation.)
- Should tool call status ("Validating output...") be surfaced to the client as a distinct SSE event type, or just as regular text tokens? (Current design: regular text, revisit in a future change if needed.)

View File

@@ -0,0 +1,28 @@
## Why
The chat backend currently proxies requests to an OpenAI-compatible API (CLIProxyAPI) via manual HttpClient calls and SSE parsing. As the agent evolves toward structured extraction with tool calling and autonomous validation loops, this manual plumbing becomes a liability. Semantic Kernel provides a production-ready abstraction for chat completion, tool/function calling, and auto-invocation — letting us focus on agent behavior rather than HTTP mechanics. Adopting it now establishes the foundation for the agentic workflow (natural language → structured extraction → tool-based validation → human-in-the-loop clarification).
## What Changes
- Replace manual HttpClient + SSE proxy in `ChatController` with Semantic Kernel's `OpenAIChatCompletionService` pointed at the existing CLIProxyAPI proxy
- Add a validation plugin that the agent can call as a tool to validate extracted key-value output against a predefined schema
- Introduce an agentic loop: the kernel autonomously retries extraction up to 23 times on validation failure, then escalates to the user for clarification
- Keep the existing SSE contract to the Blazor client unchanged — `ChatApiClient` and `Chat.razor` are not modified
- **BREAKING**: `ChatController` internals are rewritten; the manual Responses API proxy logic is removed entirely
## Capabilities
### New Capabilities
- `agent-extraction`: Defines the structured field extraction behavior — predefined keys, validation rules, autonomous retry loop, and human-in-the-loop escalation
- `semantic-kernel-integration`: Defines how Semantic Kernel is configured, registered, and wired into the API — kernel setup, OpenAI connector config, plugin registration
### Modified Capabilities
- `chat-streaming`: The streaming requirement changes from "proxy SSE from upstream API" to "stream Semantic Kernel chat completion responses as SSE" — same client contract, different server implementation
## Impact
- **ChatAgent.Api**: New NuGet dependencies (`Microsoft.SemanticKernel`), `Program.cs` service registration changes, `ChatController` rewritten
- **ChatAgent.Api.Tests**: Existing `ChatControllerTests` need updating to mock Semantic Kernel services instead of upstream HTTP calls
- **Dependencies**: Adds `Microsoft.SemanticKernel` and `Microsoft.SemanticKernel.Connectors.OpenAI` packages
- **Infrastructure**: No change — still talks to CLIProxyAPI at `localhost:8317`
- **Client**: No change — SSE contract preserved

View File

@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: Structured field extraction from natural language
The agent SHALL extract a predefined set of key-value pairs from user-provided natural language text (e.g., email content) and return them as a structured JSON object.
#### Scenario: All fields extracted successfully
- **WHEN** the user sends a message containing natural language with all required information
- **THEN** the agent returns a JSON object with all predefined fields populated from the text
#### Scenario: Partial extraction
- **WHEN** the user sends a message that contains some but not all required fields
- **THEN** the agent extracts available fields and leaves missing fields as null
### 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.
#### 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
### 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.
#### Scenario: Validation passes
- **WHEN** the agent calls the validation tool with a complete and correct extraction
- **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
- **THEN** the agent re-reads the source text and attempts to fix the extraction without user intervention
### 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.
#### Scenario: Agent retries and succeeds
- **WHEN** validation fails on the first attempt but the error is recoverable
- **THEN** the agent retries extraction and calls validation again, up to 3 total attempts
#### Scenario: Agent exhausts retries and escalates
- **WHEN** validation fails after 3 attempts
- **THEN** the agent sends a natural language message to the user identifying the specific fields it could not resolve and asking for clarification
### 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.
#### 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: 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,42 @@
## MODIFIED Requirements
### 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.
#### Scenario: Successful chat request
- **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
### Requirement: Streaming response delivery
The API backend SHALL stream the Semantic Kernel's chat completion response back to the WASM client as `text/event-stream`, forwarding text content so the client can render tokens incrementally. The SSE event format MUST remain `data: {"text":"..."}\n\n` for text deltas and `data: [DONE]\n\n` for completion.
#### Scenario: Tokens stream to client
- **WHEN** the Semantic Kernel emits streaming chat message content
- **THEN** the backend forwards each content chunk as an SSE event to the client containing the text fragment
#### Scenario: Stream completes
- **WHEN** the Semantic Kernel streaming response completes
- **THEN** the backend signals stream completion to the client with `data: [DONE]\n\n`
### Requirement: Configurable proxy target
The CLIProxyAPI base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded. These values are used to configure the Semantic Kernel OpenAI connector.
#### Scenario: Configuration read at startup
- **WHEN** the API starts
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration to configure the Semantic Kernel
### Requirement: Error propagation
If the LLM service returns an error or is unreachable, the API backend SHALL return an error SSE event and the client SHALL display the error to the user.
#### Scenario: LLM service unreachable
- **WHEN** the CLIProxyAPI proxy is not running
- **THEN** the client displays an error message instead of an assistant response

View File

@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Semantic Kernel service registration
The API backend SHALL register a Semantic Kernel `Kernel` instance in the ASP.NET Core DI container at startup, configured with an OpenAI chat completion connector.
#### Scenario: Kernel registered at startup
- **WHEN** the API application starts
- **THEN** a `Kernel` instance is available for injection into controllers
### Requirement: OpenAI connector targets CLIProxyAPI proxy
The Semantic Kernel OpenAI chat completion service SHALL be configured to use the existing CLIProxyAPI proxy endpoint as its base URL, reading the URL and model name from `appsettings.json`.
#### Scenario: Connector uses configured endpoint
- **WHEN** the kernel makes a chat completion request
- **THEN** it sends the request to the URL specified in `ResponsesApi:BaseUrl` configuration
#### Scenario: Model from configuration
- **WHEN** the kernel makes a chat completion request
- **THEN** it uses the model name specified in `ResponsesApi:Model` configuration
### Requirement: Plugin registration
The API backend SHALL register extraction and validation plugins with the Kernel so they are available as tools for the LLM to invoke.
#### Scenario: Plugins available as tools
- **WHEN** the kernel is constructed
- **THEN** all registered plugin functions appear in the tool list sent to the LLM
### Requirement: Auto function calling
The Kernel SHALL be configured with automatic function calling enabled, allowing the LLM to invoke registered plugin functions without manual dispatch code.
#### Scenario: LLM invokes tool automatically
- **WHEN** the LLM decides to call a registered function during chat completion
- **THEN** the kernel automatically executes the function and returns the result to the LLM

View File

@@ -0,0 +1,40 @@
## 1. Add Semantic Kernel Dependencies
- [x] 1.1 Add `Microsoft.SemanticKernel` and `Microsoft.SemanticKernel.Connectors.OpenAI` NuGet packages to `ChatAgent.Api`
- [x] 1.2 Remove the `OpenAI` SDK package if no longer needed after migration
## 2. Define Extraction Schema
- [x] 2.1 Create `ExtractedFields` class in `ChatAgent.Shared/Models/` with the predefined set of key-value fields (placeholder fields until real schema is provided)
- [x] 2.2 Create `ValidationResult` class in `ChatAgent.Shared/Models/` with `IsValid`, `Errors` properties
## 3. Create Extraction Plugin
- [x] 3.1 Create `ExtractionPlugin` class in `ChatAgent.Api/Plugins/` with a `[KernelFunction]` validation method that checks `ExtractedFields` for required fields and type correctness
- [x] 3.2 Add inline tutorial comments explaining SK plugin concepts (`[KernelFunction]`, `[Description]`, auto-invocation)
## 4. Wire Semantic Kernel in Program.cs
- [x] 4.1 Register `OpenAIChatCompletionService` in DI using `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from config
- [x] 4.2 Register `Kernel` with `AddKernel()` and import `ExtractionPlugin`
- [x] 4.3 Add inline tutorial comments explaining kernel setup, connectors, and plugin registration
## 5. Rewrite ChatController
- [x] 5.1 Replace `IHttpClientFactory` and `IConfiguration` injection with `Kernel` injection
- [x] 5.2 Replace manual HTTP proxy logic with `IChatCompletionService.GetStreamingChatMessageContentsAsync()` using the conversation history from the request
- [x] 5.3 Configure `OpenAIPromptExecutionSettings` with `FunctionChoiceBehavior.Auto()` and `autoInvokeMaxCallCount = 3`
- [x] 5.4 Re-emit streaming content as the existing SSE format (`data: {"text":"..."}\n\n` and `data: [DONE]\n\n`)
- [x] 5.5 Add inline tutorial comments explaining streaming chat completion, execution settings, and tool call behavior
## 6. Update Tests
- [x] 6.1 Update `ChatControllerTests` to mock `IChatCompletionService` instead of upstream HTTP calls
- [x] 6.2 Add tests for the validation plugin (`ExtractionPlugin` returns correct pass/fail results)
- [x] 6.3 Add a test verifying the agent escalates to the user after max retries
## 7. Verify
- [x] 7.1 Run `dotnet build` to confirm no errors
- [x] 7.2 Run `dotnet test` to confirm all tests pass
- [ ] 7.3 Manual smoke test: send a chat message and verify streaming still works end-to-end through SK

View File

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

View File

@@ -0,0 +1,30 @@
## Context
Chat.razor currently constructs a `ChatRequest` with only the latest user message (line 161-164). The backend and Responses API already support multi-message input — no server changes needed. This is purely a client-side change.
## Goals / Non-Goals
**Goals:**
- Send full conversation history with each request for multi-turn context
- Add a "New Chat" button to reset the session
**Non-Goals:**
- Persistent conversation storage (explicitly deferred)
- Multiple conversation tabs/sidebar
- Token limit management or history truncation (small conversations for now)
## Decisions
### Decision 1: Send full _messages list minus the empty placeholder
When building the `ChatRequest`, include all messages from `_messages` except the last one (which is the empty assistant placeholder waiting for streaming). This gives the AI the full conversation context.
### Decision 2: New Chat button in the AppBar
Place a "New Chat" icon button in the `MudAppBar` (in MainLayout.razor or Chat.razor). Clicking it clears `_messages` and resets to the empty state. Disabled during streaming.
Alternative considered: putting it in the input area. Rejected — the AppBar is more natural and matches ChatGPT/Gemini placement.
## Risks / Trade-offs
- [No token limit management] → For long conversations, the full history could exceed the model's context window. Acceptable for now; truncation can be added later.

View File

@@ -0,0 +1,21 @@
## Why
Each chat request currently sends only the latest user message, so the AI has no memory of previous exchanges within the same session. Users expect the assistant to remember context from earlier in the conversation, like ChatGPT/Gemini do.
## What Changes
- Send the full conversation history (all prior user and assistant messages) with each API request instead of just the latest user message
- The backend already forwards all messages in `ChatRequest.Messages` to the Responses API — no backend changes needed
- Add a "New Chat" button to clear the conversation and start fresh
## Capabilities
### New Capabilities
<!-- None -->
### Modified Capabilities
- `chat-ui`: Send full message history with each request; add a "New Chat" button to reset the conversation
## Impact
- `src/ChatAgent.Client/Pages/Chat.razor`: Change request construction to include full history; add new chat button

View File

@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Streaming AI response
The assistant SHALL reply with a real AI response streamed from the backend API, using the full conversation history as context. Tokens appear incrementally as they arrive.
#### Scenario: Bot replies with streamed AI response
- **WHEN** the user sends any message
- **THEN** the assistant message appears and grows token by token as the stream delivers text
#### Scenario: Full history sent with each request
- **WHEN** the user sends a message after prior exchanges
- **THEN** all previous user and assistant messages are included in the API request so the AI has conversational context
## ADDED Requirements
### Requirement: New chat button
The chat page SHALL provide a button to clear the current conversation and start a new one.
#### Scenario: User starts a new chat
- **WHEN** the user clicks the "New Chat" button
- **THEN** all messages are cleared and the empty state is shown
#### Scenario: New chat button disabled during streaming
- **WHEN** the assistant is currently streaming a response
- **THEN** the "New Chat" button is disabled

View File

@@ -0,0 +1,13 @@
## 1. Multi-turn History
- [x] 1.1 Change ChatRequest construction in Chat.razor to send all prior messages (excluding the empty assistant placeholder) instead of just the latest user message
## 2. New Chat Button
- [x] 2.1 Add a "New Chat" icon button to the AppBar or chat page that clears _messages
- [x] 2.2 Disable the "New Chat" button while streaming is in progress
## 3. Verify
- [x] 3.1 Run dotnet build to confirm no errors
- [x] 3.2 Run dotnet test to confirm existing tests still pass

View File

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

View File

@@ -0,0 +1,29 @@
## 1. Shared Models
- [x] 1.1 Create ChatRequest.cs in ChatAgent.Shared/Models with a Messages list property
## 2. API Backend
- [x] 2.1 Add appsettings.json to ChatAgent.Api with ResponsesApi:BaseUrl and ResponsesApi:Model
- [x] 2.2 Register an HttpClient for the Responses API proxy in Api Program.cs
- [x] 2.3 Create ChatController with POST /api/chat that proxies to the Responses API with streaming
- [x] 2.4 Parse Responses API SSE stream, extract response.output_text.delta events, re-emit as simplified SSE to client
## 3. Client Streaming
- [x] 3.1 Add a streaming SendChatAsync method to ChatApiClient that uses SetBrowserResponseStreamingEnabled and HttpCompletionOption.ResponseHeadersRead
- [x] 3.2 Parse the simplified SSE stream line-by-line, yielding text deltas
## 4. Chat Page Updates
- [x] 4.1 Replace hardcoded response in Chat.razor with a call to ChatApiClient.SendChatAsync
- [x] 4.2 Append tokens to the assistant message incrementally with StateHasChanged after each delta
- [x] 4.3 Add a thinking indicator shown until the first token arrives
- [x] 4.4 Disable input field and send button while streaming is in progress
- [x] 4.5 Handle errors — display error message if API call fails
- [x] 4.6 Auto-scroll during streaming (not just at the end)
## 5. Verify
- [x] 5.1 Run dotnet build to confirm no errors
- [ ] 5.2 Manually verify: send a message, see streaming response from Claude

View File

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

View File

@@ -0,0 +1,57 @@
## Context
Assistant messages from the LLM contain markdown formatting (bold, code blocks, lists, headings) but are currently rendered as plain text via `<MudText>@message.Content</MudText>`. Markdig 1.1.1 is already in the stack spec but not yet wired up. The Blazor WASM client needs a rendering pipeline that converts markdown to HTML and displays it safely inside chat bubbles.
## Goals / Non-Goals
**Goals:**
- Render assistant message markdown as formatted HTML (headings, bold, italic, code, lists, tables, links)
- Sanitize HTML output to prevent XSS from LLM-generated content
- Style rendered elements to look good inside MudBlazor chat bubbles
- Maintain streaming performance — re-render as tokens arrive without flicker
**Non-Goals:**
- Rendering user messages as markdown (they stay plain text)
- Syntax highlighting for code blocks (future enhancement)
- LaTeX/math rendering
- Image rendering from markdown
## Decisions
### D1: Use Markdig for markdown-to-HTML conversion
Markdig is already in the stack spec. It's the standard .NET markdown library, runs in WASM, and supports GFM (GitHub Flavored Markdown) extensions out of the box.
**Alternative**: Custom regex-based parsing — rejected, fragile and incomplete.
### D2: Render via `MarkupString` in Blazor
Blazor's `MarkupString` struct renders raw HTML in the component tree. We wrap the Markdig HTML output in `MarkupString` to display it. This replaces `@message.Content` with `@((MarkupString)renderedHtml)` for assistant messages only.
**Alternative**: Use a third-party Blazor markdown component — rejected, adds a dependency when Markdig + MarkupString achieves the same result with less coupling.
### D3: HTML sanitization via allowlist approach
LLM output is untrusted. After Markdig converts markdown to HTML, we strip any tags/attributes not on an allowlist. Allowed: `p, h1-h6, strong, em, code, pre, ul, ol, li, a (href only), table, thead, tbody, tr, th, td, br, blockquote`. This prevents script injection without a heavy sanitizer dependency.
**Alternative**: Use a NuGet sanitizer package (e.g., HtmlSanitizer) — viable but adds a dependency for a simple allowlist. If the allowlist proves insufficient, revisit.
### D4: Create a MarkdownService for the conversion pipeline
A `MarkdownService` class encapsulates the Markdig pipeline configuration, HTML sanitization, and caching. Registered as singleton in DI. This keeps the rendering logic out of the Razor component and makes it testable.
### D5: Incremental rendering during streaming
During streaming, the assistant message content grows token by token. Each time `StateHasChanged()` is called, the full content string is re-converted through Markdig. This is acceptable because:
- Markdig is fast (sub-millisecond for typical message sizes)
- Messages rarely exceed a few KB
- No DOM diffing overhead — Blazor handles this efficiently
If performance becomes an issue, we can cache the last-known-good HTML prefix and only re-render the delta.
## Risks / Trade-offs
- **[XSS from LLM output]** → Mitigated by HTML sanitization allowlist. The allowlist is conservative — only structural tags, no script/style/event handlers.
- **[Streaming flicker]** → Low risk. Blazor's diffing minimizes DOM updates. If observed, add debouncing to StateHasChanged calls.
- **[Markdig WASM size]** → Markdig adds ~200KB to the WASM bundle. Already accepted in stack spec.
- **[Incomplete markdown support]** → Markdig supports GFM by default. Edge cases (nested tables, complex HTML blocks) may render imperfectly but are rare in LLM output.

View File

@@ -0,0 +1,24 @@
## Why
Assistant messages currently render as plain text — markdown formatting (bold, code blocks, lists, headings) from the LLM appears as raw characters. With Semantic Kernel and tool calling now in place, responses are increasingly structured and harder to read without proper rendering. Markdig 1.1.1 is already in the stack but not wired up.
## What Changes
- Render assistant message content as HTML by converting markdown via Markdig
- Sanitize rendered HTML to prevent XSS (the LLM output is untrusted content)
- Style rendered markdown elements (code blocks, lists, tables) to fit the chat bubble aesthetic
- Keep user messages as plain text (they are short inputs, not markdown)
## Capabilities
### New Capabilities
- `rich-text-display`: Markdown-to-HTML rendering pipeline for assistant messages, including sanitization and styling
### Modified Capabilities
- `chat-ui`: Assistant message display changes from plain text to rendered markdown
## Impact
- ChatAgent.Client: Chat.razor message rendering, new markdown service, CSS additions
- Dependencies: Markdig already in stack spec; may need an HTML sanitizer package
- No backend changes — this is purely client-side rendering

View File

@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Message display
The chat page SHALL display messages in a vertically scrolling list, with each message showing the sender role (user or assistant), the message content, and a visual distinction between user and assistant messages (e.g., alignment, color, or avatar). Assistant messages SHALL render content as formatted HTML converted from markdown; user messages SHALL display as plain text.
#### Scenario: User message displayed
- **WHEN** the user sends a message
- **THEN** the message appears in the message list aligned or styled to indicate it is from the user, rendered as plain text
#### Scenario: Assistant message displayed
- **WHEN** the assistant responds
- **THEN** the response appears in the message list with distinct styling from user messages, with markdown content rendered as formatted HTML
#### Scenario: Message ordering
- **WHEN** multiple messages exist in the conversation
- **THEN** messages are displayed in chronological order, oldest at top

View File

@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Markdown rendering for assistant messages
The system SHALL convert assistant message content from markdown to formatted HTML using Markdig, displaying headings, bold, italic, code blocks, lists, tables, links, and blockquotes with proper visual formatting.
#### Scenario: Markdown bold and italic rendered
- **WHEN** an assistant message contains `**bold**` or `*italic*` text
- **THEN** the text is displayed with bold or italic formatting respectively
#### Scenario: Code block rendered
- **WHEN** an assistant message contains a fenced code block (triple backticks)
- **THEN** the code is displayed in a monospace font within a visually distinct block
#### Scenario: Inline code rendered
- **WHEN** an assistant message contains inline code (single backticks)
- **THEN** the code is displayed in a monospace font with a subtle background
#### Scenario: List rendered
- **WHEN** an assistant message contains a markdown list (ordered or unordered)
- **THEN** the list is displayed with proper indentation and bullet/number markers
#### Scenario: Heading rendered
- **WHEN** an assistant message contains markdown headings (# through ######)
- **THEN** the headings are displayed with appropriate size and weight
#### Scenario: Link rendered
- **WHEN** an assistant message contains a markdown link `[text](url)`
- **THEN** the link is displayed as a clickable hyperlink opening in a new tab
#### Scenario: Table rendered
- **WHEN** an assistant message contains a markdown table
- **THEN** the table is displayed with borders, header row styling, and proper alignment
### Requirement: HTML sanitization
The system SHALL sanitize all HTML output from the markdown renderer to prevent cross-site scripting (XSS) attacks from LLM-generated content.
#### Scenario: Script tags stripped
- **WHEN** assistant message content contains `<script>` tags
- **THEN** the script tags and their content are removed from the rendered output
#### Scenario: Event handlers stripped
- **WHEN** assistant message content contains HTML attributes like `onclick` or `onerror`
- **THEN** the attributes are removed from the rendered output
#### Scenario: Safe tags preserved
- **WHEN** assistant message content contains allowed structural HTML (p, strong, em, code, pre, ul, ol, li, a, table elements, blockquote, br, h1-h6)
- **THEN** the tags are preserved in the rendered output
### Requirement: Markdown styling within chat bubbles
The system SHALL style rendered markdown elements to be visually consistent with the MudBlazor chat bubble theme.
#### Scenario: Code block styled in bubble
- **WHEN** a code block is rendered inside an assistant chat bubble
- **THEN** it has a distinct background color, padding, border-radius, and does not overflow the bubble width (horizontal scroll if needed)
#### Scenario: Paragraph spacing in bubble
- **WHEN** multiple paragraphs are rendered inside an assistant chat bubble
- **THEN** paragraphs have appropriate spacing without excessive gaps
### Requirement: Streaming compatibility
The system SHALL re-render markdown as new tokens arrive during streaming without visual glitches.
#### Scenario: Partial markdown rendered during streaming
- **WHEN** tokens are arriving and the current content contains incomplete markdown (e.g., a code block not yet closed)
- **THEN** the content is rendered with best-effort formatting and updates smoothly as more tokens complete the markdown structure
### Requirement: User messages remain plain text
The system SHALL continue to render user messages as plain text without markdown processing.
#### Scenario: User message not processed as markdown
- **WHEN** a user message contains markdown syntax like `**bold**`
- **THEN** the raw text `**bold**` is displayed, not formatted bold text

View File

@@ -0,0 +1,26 @@
## 1. Markdown Service
- [x] 1.1 Add Markdig NuGet package to ChatAgent.Client project
- [x] 1.2 Create MarkdownService class with a ConfigureMarkdigPipeline method using AdvancedExtensions (GFM tables, task lists, pipe tables)
- [x] 1.3 Add ConvertToHtml method that takes a markdown string and returns sanitized HTML
- [x] 1.4 Implement 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)
- [x] 1.5 Register MarkdownService as singleton in Program.cs
## 2. Chat Component Integration
- [x] 2.1 Inject MarkdownService into Chat.razor
- [x] 2.2 For assistant messages, replace `@message.Content` with `@((MarkupString)MarkdownService.ConvertToHtml(message.Content))`
- [x] 2.3 Keep user messages rendering as plain text via `@message.Content`
- [x] 2.4 Verify streaming still works — markdown re-renders as tokens arrive
## 3. CSS Styling
- [x] 3.1 Add CSS for rendered markdown inside assistant bubbles: code blocks (background, padding, border-radius, overflow-x auto), inline code (background, padding), paragraphs (margin control), lists (indentation), tables (borders, header styling), blockquotes (left border, padding), links (color, underline)
- [x] 3.2 Ensure code blocks do not overflow bubble width — add horizontal scroll
- [x] 3.3 Ensure last paragraph/element in bubble has no bottom margin (tight fit)
## 4. Testing
- [x] 4.1 Add unit tests for MarkdownService: bold/italic, code blocks, lists, headings, tables, links
- [x] 4.2 Add sanitization tests: script tags stripped, event handlers stripped, safe tags preserved
- [x] 4.3 Manual test: send a message asking LLM to respond with markdown formatting and verify rendering

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

@@ -1,29 +0,0 @@
## 1. Shared Models
- [ ] 1.1 Create ChatRequest.cs in ChatAgent.Shared/Models with a Messages list property
## 2. API Backend
- [ ] 2.1 Add appsettings.json to ChatAgent.Api with ResponsesApi:BaseUrl and ResponsesApi:Model
- [ ] 2.2 Register an HttpClient for the Responses API proxy in Api Program.cs
- [ ] 2.3 Create ChatController with POST /api/chat that proxies to the Responses API with streaming
- [ ] 2.4 Parse Responses API SSE stream, extract response.output_text.delta events, re-emit as simplified SSE to client
## 3. Client Streaming
- [ ] 3.1 Add a streaming SendChatAsync method to ChatApiClient that uses SetBrowserResponseStreamingEnabled and HttpCompletionOption.ResponseHeadersRead
- [ ] 3.2 Parse the simplified SSE stream line-by-line, yielding text deltas
## 4. Chat Page Updates
- [ ] 4.1 Replace hardcoded response in Chat.razor with a call to ChatApiClient.SendChatAsync
- [ ] 4.2 Append tokens to the assistant message incrementally with StateHasChanged after each delta
- [ ] 4.3 Add a thinking indicator shown until the first token arrives
- [ ] 4.4 Disable input field and send button while streaming is in progress
- [ ] 4.5 Handle errors — display error message if API call fails
- [ ] 4.6 Auto-scroll during streaming (not just at the end)
## 5. Verify
- [ ] 5.1 Run dotnet build to confirm no errors
- [ ] 5.2 Manually verify: send a message, see streaming response from Claude

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,76 @@
# OpenSpec Portable Variant
## For hand-typing into target's OpenSpec on the sandbox
Type these two files into the target's `openspec/changes/add-sk-chat/` directory.
Then run `/opsx:apply` and let the AI implement from the tasks.
**Lines to type**: ~25 | **vs code recipe**: ~120 lines | **Compression**: 4.8x
---
## File 1: proposal.md
```markdown
# Add SK Chat Endpoint with Tool Calling
## Why
Need an AI chat endpoint that streams responses and supports
autonomous tool calling for structured data extraction/validation.
## What Changes
- Add Semantic Kernel 1.74.0 with OpenAI connector
- POST /api/chat endpoint streaming SSE via SK
- ExtractionPlugin with [KernelFunction] for field validation
- Shared models: ChatRequest, ChatMessage, ExtractedFields, ValidationResult
## Impact
- API project: new controller, plugin, DI wiring
- Shared project: new models
- Config: ResponsesApi section in appsettings.json
```
## File 2: tasks.md
```markdown
## 1. Shared Models
- [ ] 1.1 Create ChatMessage (Role string, Content string, Timestamp DateTime)
- [ ] 1.2 Create ChatRequest (Messages List<ChatMessage>)
- [ ] 1.3 Create ExtractedFields with required fields: Client, Project, Hours(decimal?), Rate(decimal?), Currency, Date; optional: Description, PoNumber
- [ ] 1.4 Create ValidationResult (IsValid bool, Errors List<string>)
## 2. Extraction Plugin
- [ ] 2.1 Create ExtractionPlugin class with [KernelFunction("validate_extracted_fields")]
- [ ] 2.2 Accepts fieldsJson string, deserializes to ExtractedFields with PropertyNameCaseInsensitive
- [ ] 2.3 Validates required fields non-null/non-empty, decimals > 0
- [ ] 2.4 Returns JSON serialized ValidationResult
## 3. Chat Controller
- [ ] 3.1 Create ChatController [ApiController] Route("api/[controller]") injecting Kernel
- [ ] 3.2 POST endpoint: set response to text/event-stream, no-cache
- [ ] 3.3 Convert request messages to SK ChatHistory (user/assistant roles)
- [ ] 3.4 Import ExtractionPlugin per-request via _kernel.ImportPluginFromObject
- [ ] 3.5 Use OpenAIPromptExecutionSettings with FunctionChoiceBehavior.Auto()
- [ ] 3.6 Stream via GetStreamingChatMessageContentsAsync, emit SSE: data: {"text":"..."}\n\n
- [ ] 3.7 Emit data: [DONE]\n\n on completion
- [ ] 3.8 Handle HttpRequestException (emit error SSE), TaskCanceledException (silent)
## 4. DI Wiring (Program.cs)
- [ ] 4.1 Add using Microsoft.SemanticKernel at top of Program.cs
- [ ] 4.2 Read BaseUrl and Model from config "ResponsesApi" section
- [ ] 4.3 AddOpenAIChatCompletion(modelId, endpoint with /v1 suffix, apiKey)
- [ ] 4.4 AddKernel()
- [ ] 4.5 AddSingleton<ExtractionPlugin>()
## 5. Configuration
- [ ] 5.1 Add ResponsesApi section to appsettings.json: BaseUrl "http://localhost:8317/v1", Model "claude-sonnet-4-6"
- [ ] 5.2 Add NuGet: Microsoft.SemanticKernel 1.74.0, Microsoft.SemanticKernel.Connectors.OpenAI 1.74.0
```
---
## Usage on sandbox
1. Create the change: `openspec new change "add-sk-chat"`
2. Type `proposal.md` into `openspec/changes/add-sk-chat/proposal.md`
3. Type `tasks.md` into `openspec/changes/add-sk-chat/tasks.md`
4. Run `/opsx:apply add-sk-chat` — the AI implements all tasks

View File

@@ -0,0 +1,251 @@
# Feature Recipe: Semantic Kernel Chat with Tool Calling
**Source**: migrate-to-semantic-kernel + wire-responses-api | **Lines to type**: ~120
**Included**: wire-responses-api (SSE streaming), migrate-to-semantic-kernel (SK + plugins)
**Skipped**: wire-responses-api's manual HttpClient proxy (superseded by SK)
**Skipped**: basic-chat-interface (UI — not selected), multi-turn-conversations (not selected)
---
## Prerequisites
Add to your API `.csproj`:
```xml
<PackageReference Include="Microsoft.SemanticKernel" Version="1.74.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />
```
---
## Step 1: New file — Shared/Models/ChatMessage.cs (~6 lines)
```csharp
namespace __YOUR_NAMESPACE__.Shared.Models
{
public class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
}
```
## Step 2: New file — Shared/Models/ChatRequest.cs (~6 lines)
```csharp
namespace __YOUR_NAMESPACE__.Shared.Models
{
public class ChatRequest
{
public List<ChatMessage> Messages { get; set; } = new();
}
}
```
## Step 3: New file — Shared/Models/ExtractedFields.cs (~12 lines)
```csharp
// ADAPT: Replace these fields with your domain's extraction schema
namespace __YOUR_NAMESPACE__.Shared.Models
{
public class ExtractedFields
{
public string? Client { get; set; }
public string? Project { get; set; }
public decimal? Hours { get; set; }
public decimal? Rate { get; set; }
public string? Currency { get; set; }
public string? Date { get; set; }
public string? Description { get; set; }
public string? PoNumber { get; set; }
}
}
```
## Step 4: New file — Shared/Models/ValidationResult.cs (~5 lines)
```csharp
namespace __YOUR_NAMESPACE__.Shared.Models
{
public class ValidationResult
{
public bool IsValid { get; set; }
public List<string> Errors { get; set; } = new();
}
}
```
## Step 5: New file — Plugins/ExtractionPlugin.cs (~35 lines)
```csharp
// ADAPT: Change RequiredFields and validation logic to match your ExtractedFields
using System.ComponentModel;
using System.Text.Json;
using __YOUR_NAMESPACE__.Shared.Models;
using Microsoft.SemanticKernel;
namespace __YOUR_NAMESPACE__.Api.Plugins
{
public class ExtractionPlugin
{
private static readonly string[] RequiredFields =
{ "Client", "Project", "Hours", "Rate", "Currency", "Date" };
[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)
{
var result = new ValidationResult();
ExtractedFields? fields;
try
{
fields = JsonSerializer.Deserialize<ExtractedFields>(fieldsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException ex)
{
result.IsValid = false;
result.Errors.Add($"Invalid JSON: {ex.Message}");
return JsonSerializer.Serialize(result);
}
if (fields == null)
{
result.IsValid = false;
result.Errors.Add("Deserialized fields object is null");
return JsonSerializer.Serialize(result);
}
if (string.IsNullOrWhiteSpace(fields.Client))
result.Errors.Add("Missing required field: Client");
if (string.IsNullOrWhiteSpace(fields.Project))
result.Errors.Add("Missing required field: Project");
if (fields.Hours == null || fields.Hours <= 0)
result.Errors.Add("Missing or invalid required field: Hours (must be positive)");
if (fields.Rate == null || fields.Rate <= 0)
result.Errors.Add("Missing or invalid required field: Rate (must be positive)");
if (string.IsNullOrWhiteSpace(fields.Currency))
result.Errors.Add("Missing required field: Currency");
if (string.IsNullOrWhiteSpace(fields.Date))
result.Errors.Add("Missing required field: Date");
result.IsValid = result.Errors.Count == 0;
return JsonSerializer.Serialize(result);
}
}
}
```
## Step 6: New file — Controllers/ChatController.cs (~40 lines)
```csharp
using System.Text.Json;
using __YOUR_NAMESPACE__.Api.Plugins;
using __YOUR_NAMESPACE__.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
namespace __YOUR_NAMESPACE__.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ChatController : ControllerBase
{
private readonly Kernel _kernel;
public ChatController(Kernel kernel) { _kernel = kernel; }
[HttpPost]
public async Task Post([FromBody] ChatRequest request)
{
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
try
{
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
var chatHistory = new ChatHistory();
foreach (var msg in request.Messages)
{
if (msg.Role == "user") chatHistory.AddUserMessage(msg.Content);
else if (msg.Role == "assistant") chatHistory.AddAssistantMessage(msg.Content);
}
var plugin = HttpContext.RequestServices.GetRequiredService<ExtractionPlugin>();
_kernel.ImportPluginFromObject(plugin, "Extraction");
var settings = new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(
chatHistory, settings, _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) { }
}
private async Task WriteSSEAsync(string data)
{
await Response.WriteAsync($"data: {data}\n\n");
await Response.Body.FlushAsync();
}
}
}
```
## Step 7: Add to Program.cs (~12 lines)
Add `using Microsoft.SemanticKernel;` at the top.
Insert after `builder.Services.AddControllers();`:
```csharp
// ADAPT: Change BaseUrl and Model for your proxy/LLM
var baseUrl = builder.Configuration["ResponsesApi:BaseUrl"] ?? "http://localhost:8317/v1";
var model = builder.Configuration["ResponsesApi:Model"] ?? "claude-sonnet-4-6";
builder.Services.AddOpenAIChatCompletion(
modelId: model,
endpoint: new Uri(baseUrl),
apiKey: builder.Configuration["ResponsesApi:ApiKey"] ?? "not-needed");
builder.Services.AddKernel();
builder.Services.AddSingleton<__YOUR_NAMESPACE__.Api.Plugins.ExtractionPlugin>();
```
## Step 8: Add to appsettings.json (~4 lines)
Add this section:
```json
"ResponsesApi": {
"BaseUrl": "http://localhost:8317/v1",
"Model": "claude-sonnet-4-6"
}
```
---
## Gotchas
- **Base URL must include `/v1`** — the OpenAI SDK appends `chat/completions` directly, so without `/v1` you get a 404
- **`using Microsoft.SemanticKernel;`** is required in Program.cs for the `AddOpenAIChatCompletion` extension method to resolve
- **Plugin is imported per-request** via `_kernel.ImportPluginFromObject` — do not register it in the kernel at startup
- **CORS**: If your Blazor client is on a different port, add CORS policy in Program.cs before `app.Run()`

View File

@@ -0,0 +1,63 @@
# Feature: SK Chat Endpoint with Tool Calling
## Target: ApplicationX (ASP.NET Core + Blazor WASM + MudBlazor)
**Included**: wire-responses-api (superseded — SK version used), migrate-to-semantic-kernel
**Lines to type**: ~40 | **Code equivalent**: ~120 lines | **Compression**: 3x
## Packages
- Microsoft.SemanticKernel 1.74.0
- Microsoft.SemanticKernel.Connectors.OpenAI 1.74.0
## Architecture
POST /api/chat accepts conversation messages, runs them through Semantic
Kernel's chat completion with auto tool calling, streams response as SSE.
An ExtractionPlugin lets the LLM validate structured data it extracts
from natural language, retrying autonomously before escalating to the user.
## Components
### ChatController: Controllers/ChatController.cs
- [ApiController] POST endpoint, injects Kernel via DI
- Converts List<ChatMessage> to SK ChatHistory (user/assistant roles)
- Imports ExtractionPlugin per-request via _kernel.ImportPluginFromObject
- Uses OpenAIPromptExecutionSettings with FunctionChoiceBehavior.Auto()
- Streams via GetStreamingChatMessageContentsAsync, skips empty chunks
- SSE output: `data: {"text":"..."}\n\n` per chunk, `data: [DONE]\n\n` at end
- Error: `data: {"error":"..."}\n\n` then [DONE]
- Catches TaskCanceledException silently (client disconnect)
### ExtractionPlugin: Plugins/ExtractionPlugin.cs
- [KernelFunction("validate_extracted_fields")]
- [Description] tells LLM: validates extracted fields against required schema
- Accepts string fieldsJson, deserializes to ExtractedFields
- Checks required fields non-null/non-empty, decimals > 0
- Returns JSON: {"IsValid": bool, "Errors": ["..."]}
### ExtractedFields: Shared/Models/ExtractedFields.cs
- Required: Client(string?), Project(string?), Hours(decimal?), Rate(decimal?), Currency(string?), Date(string?)
- Optional: Description(string?), PoNumber(string?)
### ValidationResult: Shared/Models/ValidationResult.cs
- IsValid(bool), Errors(List<string>)
### ChatRequest + ChatMessage: Shared/Models/
- ChatRequest: Messages(List<ChatMessage>)
- ChatMessage: Role(string), Content(string), Timestamp(DateTime)
## Wiring (Program.cs, after AddControllers)
1. `using Microsoft.SemanticKernel;` at top (required for extension methods)
2. Read BaseUrl and Model from config section "ResponsesApi"
3. AddOpenAIChatCompletion(modelId, endpoint: new Uri(baseUrl), apiKey)
4. AddKernel()
5. AddSingleton<ExtractionPlugin>()
6. CORS policy if Blazor client on different port
## Config (appsettings.json)
- ResponsesApi:BaseUrl = "http://localhost:8317/v1"
- ResponsesApi:Model = "claude-sonnet-4-6"
## Gotchas
- Base URL MUST include /v1 — OpenAI SDK appends chat/completions directly
- Plugin imported per-request, not at startup (avoids kernel state leaks)
- SK has built-in auto-invoke limit — no need to set max retries
- JsonSerializerOptions needs PropertyNameCaseInsensitive = true for deserialization

View File

@@ -0,0 +1,223 @@
# 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 Azure OpenAI (Azure AD auth via tenant ID)
- 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: Azure OpenAI with DefaultAzureCredential
**Why:** The sandbox environment uses Azure OpenAI with an Azure AD tenant ID.
SK's `AddAzureOpenAIChatCompletion()` with `DefaultAzureCredential` integrates
with CRC's existing Azure AD auth. No API keys to manage — uses the developer's
`az login` token locally and managed identity in production. The endpoint URL
does NOT need `/v1` (Azure SDK constructs the path internally).
## 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: SSE response buffering
Two streaming hops: Azure OpenAI → CRC.Server → Browser. Buffering at any point kills
streaming UX. Common culprits: response compression middleware (`UseResponseCompression()`),
reverse proxies (NGINX, IIS), Azure API Management in front of Azure OpenAI.
Use the diagnostic stream-test endpoint (see Critical Pattern #8 in reference spec)
to verify both hops stream correctly before building the UI.
## 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+. Also need `Microsoft.SemanticKernel.Connectors.AzureOpenAI`
and `Azure.Identity` packages.
## Risk: Azure AD token acquisition
`DefaultAzureCredential` tries multiple auth methods in sequence. On a developer machine,
it uses Azure CLI login (`az login --tenant <tenant-id>`). If the developer hasn't run
`az login`, SK will fail with an auth error at the first LLM call, not at startup.
## 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`, `Microsoft.SemanticKernel.Connectors.AzureOpenAI`, and `Azure.Identity` to CRC.Server. Add `Markdig` 1.1.1 to CRC.Client (if not already present). CRC may already have `Azure.Identity` — check first. 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: `AddAzureOpenAIChatCompletion()` with `DefaultAzureCredential` (tenant ID from config) + `AddKernel()`. Endpoint is Azure OpenAI resource URL (NO `/v1`). Use deployment name, NOT model name. Config from `NlxvaPricer:*` keys in appsettings.json. See Critical Pattern #2.
- [ ] **T6b: Verify streaming hop 1** — Add temporary `stream-test` diagnostic endpoint (see Critical Pattern #8). Run `curl -N` against it. Verify timestamps are spread across seconds (not clustered). Check for response compression middleware interference. Remove diagnostic endpoint after verification.
- [ ] **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` (AzureOpenAIEndpoint, DeploymentName, TenantId, FewShotPath) and `ExternalApis` sections to CRC.Server appsettings.json. Ensure CORS allows CRC.Client origin for SSE responses. Ensure developer has run `az login --tenant <tenant-id>`.
- [ ] **T15: Verify streaming end-to-end** — Run `curl -N` against `/api/nlxva-pricer/chat` to verify hop 2 (server → client) streams correctly. Check browser Network tab EventStream view for incremental token delivery. If response compression is enabled, verify SSE endpoints opt out.
- [ ] **T16: Smoke test** — Build both projects. Navigate to /nlxva-pricer. Send a chat message → verify streaming tokens appear incrementally. 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,763 @@
# Porting Guide: Natural Language XVA Pricer → CRC
## Source: ChatAgent (commit 5b027eb) | Export: nlxva-pricer-spec.md
## Date: 2026-04-07
---
## How to Use This Guide
You have three companion documents for this port:
1. **`nlxva-pricer-spec.md`** — The AI-targeted portable spec. Compact, precise. Give this to Copilot/Claude on the CRC machine. It has the exact contracts, code patterns, and DI wiring.
2. **`nlxva-pricer-openspec.md`** — The OpenSpec bundle (proposal, design, tasks). Feed this to `/opsx:apply` on the target.
3. **This guide** — For you, the human. Read it when:
- You're about to start and want the full picture (read Architecture Overview)
- The AI agent hits a problem and you need to decide how to fix it (read Task Notes + Troubleshooting)
- You want to understand WHY something was designed a certain way (read Design Decisions)
- You need to find a config value or verify a setup step (read Configuration Checklist)
**Workflow**: Let the AI agent do the heavy lifting via `/opsx:apply`. Consult this guide when it gets stuck or when you need to make an adaptation judgment call.
---
## Architecture Overview
The Natural Language XVA Pricer is a chat-based interface that lets the CVA desk interact with an AI agent to price trades using natural language. It serves two modes: **general chat** (ask questions about XVA pricing, get explanations) and **email extraction** (upload a sales email, get structured trade data back as JSON).
The data flows like this: The user types a message or drops an email `.html` file onto the chat area. The Blazor WASM client sends the request to the ASP.NET Core backend via HTTP POST. The backend processes it through **Microsoft Semantic Kernel** — an AI orchestration framework that connects to **Azure OpenAI** using Azure AD authentication (the same tenant CRC already uses). For extraction requests, the backend prepends **few-shot examples** (real email → expected JSON pairs loaded from disk) to teach the model the expected output format. The LLM can autonomously call **validation tools** (counterparty lookup, trade ID validation, currency validation, schema validation) via SK's automatic function calling. The response streams back token-by-token as **Server-Sent Events (SSE)**, and the client renders each token into the chat UI with **markdown formatting** and **XSS sanitization**.
The external dependencies are: (1) an Azure OpenAI resource with a deployed model (authenticated via Azure AD tenant ID), (2) three external APIs for validation (counterparty, trade, currency) — these are the existing CRC backend services that CRC.Server already integrates with, and (3) the `Markdig` NuGet package for markdown rendering plus `Microsoft.SemanticKernel` for LLM orchestration.
**The one thing you must understand**: this feature is an isolated page. It doesn't need Fluxor, doesn't modify CRC's data layer, and doesn't touch the Pricer/MarketData/XVA/Sales pages. It adds a controller, some services, a page, and a nav link. If something goes wrong during porting, the blast radius is limited to the new files.
---
## Design Decisions (Detailed)
### 1. Semantic Kernel with Azure OpenAI for LLM communication
**What we chose:** Microsoft Semantic Kernel (SK) as the AI orchestration layer, connecting to Azure OpenAI via Azure AD authentication.
**Why:** The core value isn't just chat — it's the **extraction agent loop**. The agent extracts trade data, calls validation tools, interprets results, retries with fixes, and escalates to the user. Without SK, you'd need to: (a) manually parse the LLM's tool-call JSON from the streaming response, (b) dispatch to the correct C# function, (c) serialize the result, (d) feed it back to the LLM, (e) handle the loop termination. SK does all of this with one line: `FunctionChoiceBehavior.Auto()`. It turns ~200 lines of manual orchestration into zero.
Azure OpenAI is the LLM backend because CRC's sandbox environment provides it with an Azure AD tenant. `DefaultAzureCredential` integrates with CRC's existing Azure AD auth — no separate API keys to manage. On a developer's machine it uses the `az login` token; in production it can use managed identity.
**What we rejected:**
- **Raw HttpClient + manual SSE parsing** — This was the original Phase 2 approach. It works for simple chat but doesn't support tool calling without writing a full agent loop. Rejected when we added extraction tools.
- **LangChain/.NET equivalent** — Considered briefly. SK is Microsoft's official offering, has first-class .NET support, and integrates cleanly with ASP.NET Core DI. LangChain's .NET port was less mature.
- **OpenAI direct (non-Azure)** — CRC's network may not allow direct OpenAI access. Azure OpenAI is within the corporate Azure tenant, which is already permitted.
- **API key auth** — Simpler to configure but keys need rotation and secure storage. Azure AD tokens are automatic and tied to the developer/service identity.
**When you'd revisit this:** If the Azure OpenAI resource is decommissioned or you need a different model provider, swap `AddAzureOpenAIChatCompletion()` for `AddOpenAIChatCompletion()` — SK abstracts the difference. Everything downstream (controller, plugins, streaming) stays identical.
**Target adaptation:** CRC uses Scrutor for assembly scanning. SK's `AddKernel()` and `AddAzureOpenAIChatCompletion()` are explicit registrations that coexist with Scrutor — no conflict. But verify that Scrutor doesn't auto-register ExtractionPlugin before your manual `AddScoped<ExtractionPlugin>()` call (it could if it scans the Plugins namespace). If it does, you'll get the plugin registered without its HttpClient dependencies. Check by looking at CRC's Scrutor scan filters.
**Azure AD prerequisite:** Developers must run `az login --tenant <tenant-id>` before starting CRC.Server. `DefaultAzureCredential` will silently fail at the first LLM call (not at startup) if the token isn't available — the error message mentions "ManagedIdentityCredential" and "EnvironmentCredential" failures, which can be confusing. The fix is always `az login`.
---
### 2. SSE streaming over WebSocket
**What we chose:** Server-Sent Events (SSE) via `text/event-stream` response type.
**Why:** SSE is unidirectional (server → client), matches the OpenAI API's native streaming format, and works through HTTP proxies and load balancers without special configuration. The client only sends complete requests via POST; the streaming is server-to-client only. WebSocket would add: connection upgrade negotiation, keep-alive pings, reconnection logic, and potential issues with CRC's reverse proxy/load balancer configuration.
**What we rejected:**
- **WebSocket** — Bidirectional, but we don't need client→server streaming. Adds complexity for no benefit. Also, CRC's deployment may use a reverse proxy that requires WebSocket upgrade configuration.
- **Long polling** — Simpler but creates discrete request/response cycles. The user wouldn't see tokens appearing smoothly.
- **SignalR** — Built on top of WebSocket with fallback. Overkill for this use case and adds a significant dependency.
**When you'd revisit this:** If you later need real-time bidirectional features (e.g., server pushing extraction status updates while the user types), WebSocket or SignalR would make sense. For the current request→stream pattern, SSE is optimal.
**Target adaptation:** Verify CRC's CORS policy allows `text/event-stream` content type. Some CORS configurations only allow `application/json`. Also verify the reverse proxy (if CRC uses one in production) doesn't buffer SSE responses — NGINX, for example, needs `proxy_buffering off` for SSE to work.
---
### 3. Typed HttpClient per external API
**What we chose:** Three separate typed HttpClient services (`CounterpartyApiClient`, `TradeApiClient`, `CurrencyApiClient`), each registered with `AddHttpClient<T>()`.
**Why:** Each external API has a different base URL and potentially different auth/headers. Typed clients give each one its own HttpClient with pre-configured settings. `IHttpClientFactory` under the hood manages socket lifetimes (avoids DNS issues and socket exhaustion). Each client has a single async method, making them trivially mockable for testing.
**What we rejected:**
- **Single shared HttpClient** — Would need URL switching logic and couldn't have per-API base URLs.
- **Named HttpClients** — `AddHttpClient("counterparty")` works but loses type safety. With typed clients, the compiler catches wrong-client injections.
- **Direct HttpClient construction** — Bypasses IHttpClientFactory, risks socket exhaustion in long-running servers.
**When you'd revisit this:** If CRC already has service clients for these external APIs (it likely does — CRC.Service handles trade querying and pricing), **use the existing ones** instead of creating new typed clients. The ExtractionPlugin would take CRC's existing service interfaces instead. This is the most likely adaptation point.
**Target adaptation:** CRC already integrates with trade and pricing APIs via `CRC.Service`. Before creating new typed HttpClients, check if:
- CRC.Service already has a counterparty lookup service
- CRC.Service already has a trade validation service
- CRC.Service already has currency validation
If yes, inject those into ExtractionPlugin instead of creating new clients. The plugin methods still return JSON strings, but internally they call CRC's existing services. This is the **highest-value adaptation** because it reuses CRC's existing auth, error handling, and connection management.
---
### 4. Per-request plugin import (not global registration)
**What we chose:** Import ExtractionPlugin into the Kernel per HTTP request, not at startup.
**Why:** ExtractionPlugin depends on typed HttpClients (CounterpartyApiClient, etc.), which are transient/scoped services. If you import the plugin into the Kernel at startup (singleton), it captures the initial HttpClient instances. On subsequent requests, those instances may be stale or disposed. Per-request import resolves a fresh ExtractionPlugin from DI each time, with fresh HttpClient instances.
**What we rejected:**
- **Global plugin registration in Program.cs** — Would capture stale scoped dependencies.
- **Making ExtractionPlugin a singleton** — Would prevent it from depending on scoped services.
**When you'd revisit this:** If ExtractionPlugin had no scoped dependencies (e.g., all its validation was local, no HTTP calls), global registration would be fine and slightly more efficient.
**Target adaptation:** If CRC's existing services are scoped (which is typical for services that touch databases or HTTP), the per-request import pattern is still required. Do not "optimize" this into a global registration.
---
### 5. FewShotService as singleton with clone-on-use
**What we chose:** Load examples from disk once at startup, cache the assembled ChatHistory prefix, clone per request.
**Why:** The few-shot examples (3 email/JSON pairs, ~10KB total) and instruction template don't change at runtime. Loading from disk on every request would add ~5ms of IO latency per extraction. The singleton loads once, and `ClonePrefix()` is a fast shallow copy (ChatHistory messages are immutable).
**What we rejected:**
- **Reload on every request** — Unnecessary IO for static data.
- **Embed examples as C# constants** — Would make it impossible to update examples without recompiling. Disk files can be edited and the service restarts.
- **Database storage** — Overkill for 3 examples. Files are simpler and human-editable.
**When you'd revisit this:** If you want to A/B test different prompt configurations without restarting the server, you'd add a reload mechanism (e.g., `IOptionsMonitor<T>` pattern or a manual reload endpoint).
**Target adaptation:** The examples path is resolved relative to `ContentRootPath`. In CRC, verify where `ContentRootPath` points — it's typically the CRC.Server project root when running locally, but may differ in deployed environments. The path is configurable via `NlxvaPricer:FewShotPath` in appsettings.json, so you can point it anywhere. Make sure the examples folder is included in CRC.Server's publish output if deploying.
---
### 6. Component-local state (no Fluxor)
**What we chose:** All state lives as private fields in the NlxvaPricer.razor component.
**Why:** This is a self-contained page. The conversation, streaming state, extraction mode, and settings don't need to be shared with other pages. Adding Fluxor would require actions, reducers, and effects for what is essentially a `List<Message>` and a few booleans. The complexity cost outweighs the benefit.
**What we rejected:**
- **Fluxor state management** — CRC uses Fluxor everywhere else, but this feature has no cross-page state needs. Forcing Fluxor would mean ~200 lines of boilerplate (action classes, reducer methods, effect handlers) for zero architectural benefit.
**When you'd revisit this:** If a future feature needs to share conversation state across pages (e.g., a conversation sidebar that persists while the user navigates to Pricer), migrate to Fluxor then. For now, YAGNI.
**Target adaptation:** If CRC's code reviewers expect Fluxor for all state, discuss with them first. The argument: Fluxor adds value when state is shared across components or needs to survive navigation. This page's state is ephemeral (lost on refresh by design) and local.
---
### 7. Markdown sanitization via allowlist (not blocklist)
**What we chose:** A strict tag/attribute allowlist that strips everything not explicitly permitted.
**Why:** The LLM's output is **untrusted**. A sufficiently creative prompt injection could make the LLM emit `<script>` tags, `<img onerror="...">`, or CSS `expression()` attacks. A blocklist ("strip `<script>` tags") will always miss edge cases. An allowlist ("only allow these 20 tags") is closed by default — anything unknown is stripped.
**What we rejected:**
- **Blocklist approach** — Always incomplete. New attack vectors appear regularly.
- **No sanitization (trust Markdig)** — Markdig is a parser, not a sanitizer. It faithfully converts markdown to HTML, including any raw HTML in the input.
- **Third-party sanitizer library** — HtmlSanitizer NuGet package exists but adds a dependency for something that's ~40 lines of regex. The custom sanitizer is simpler to audit.
**When you'd revisit this:** If you need to allow richer HTML (images, iframes for embedded content), extend the allowlist carefully rather than switching to a blocklist.
**Target adaptation:** If CRC already has an HTML sanitizer (check `CRC.Component` for reusable utilities), use theirs instead of creating MarkdownService from scratch.
---
## Source → Target Mapping
| Source (ChatAgent) | Target (CRC) | Notes |
|---|---|---|
| `ChatAgent.Api/` | `CRC.Server/` | CRC uses hosted Blazor model; server serves both API and client |
| `ChatAgent.Client/` | `CRC.Client/` | Same WASM model |
| `ChatAgent.Shared/` | `CRC.Shared/` | DTOs go here — follow CRC naming (`*Dto`, `*Request`, `*Response`) |
| `Program.cs` (standalone) | `Program.cs` or `Startup.cs` | CRC may use Startup.cs pattern. Add DI registrations there. |
| `builder.Services.AddScoped<T>()` | Check Scrutor scan filters | Scrutor may auto-register; verify no duplicate |
| `appsettings.json` | `appsettings.json` (secondary config) | CRC's primary config is `gv_web_config.csv`. Use appsettings for LLM/API URLs |
| `AddCors()` | Existing CORS policy | CRC already has CORS for its Client. Verify it covers new endpoints |
| `[ApiController]` on controllers | Same pattern | CRC uses `[ApiController]` on controllers |
| `AddHttpClient<T>()` | Same pattern OR use existing CRC.Service clients | **Key decision point** — see Design Decision #3 |
| MudBlazor 9.2.0 | CRC's MudBlazor version | Check version. API differences between MudBlazor 6.x and 9.x are significant |
| `AppBar Dense (48px)` | `AppBar Regular (64px)` | **CSS must use 64px**, not 48px |
| `@page "/sales-assistant"` | `@page "/nlxva-pricer"` | Route change |
| `ChatApiClient` (typed) | `NlxvaPricerApiClient` (typed) | Follow CRC naming convention |
| `ILogger<T>` (default) | `ILogger<T>` via Serilog | Same interface, CRC's Serilog handles sinks |
| No auth | `[Authorize(Policy = Policy.ValidUser)]` | CRC uses policy-based auth — add attribute if required |
| `public partial class Program { }` | Check if CRC already has this | Needed for WebApplicationFactory in tests |
| `.NET 9` | `.NET 8` | Verify all NuGet packages target .NET 8+ |
### MudBlazor Version Warning
CRC's MudBlazor version is critical. The source uses MudBlazor 9.2.0. If CRC uses an earlier version (6.x or 7.x), several APIs differ:
- `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 `Microsoft.SemanticKernel.Connectors.AzureOpenAI` to `CRC.Server.csproj`
3. Add `Azure.Identity` to `CRC.Server.csproj` (check first: `grep -i Azure.Identity CRC.Server.csproj` — CRC may already have it since it uses Azure AD)
4. Add `Markdig` to `CRC.Client.csproj` (check if it's already there: `grep -i markdig CRC.Client.csproj`)
5. 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).
- **Azure.Identity version conflict**. If CRC already has `Azure.Identity` at a different version, the SK transitive dependency may conflict. Run `dotnet list CRC.Server package --include-transitive | grep Azure.Identity` to check.
- **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 with Azure OpenAI
**Prerequisites:** T1 (NuGet packages installed). Developer has run `az login --tenant <tenant-id>`.
**Context:** This registers the SK Kernel and Azure OpenAI chat completion connector in DI. Unlike the source project (which used a local proxy), the CRC sandbox uses Azure OpenAI with Azure AD authentication. The key differences: use `AddAzureOpenAIChatCompletion()` (not `AddOpenAIChatCompletion()`), use deployment name (not model name), endpoint has NO `/v1` suffix, and auth uses `DefaultAzureCredential` with the tenant ID.
**Step-by-step:**
1. Add `using Microsoft.SemanticKernel;` and `using Azure.Identity;` to the startup file
2. Read config values from `NlxvaPricer:*` section (AzureOpenAIEndpoint, DeploymentName, TenantId)
3. Register: `AddAzureOpenAIChatCompletion()` then `AddKernel()`
4. The endpoint is the Azure resource URL — do NOT add `/v1` (the Azure SDK handles path construction)
5. Use `DefaultAzureCredential` with the tenant ID
```csharp
var azureEndpoint = builder.Configuration["NlxvaPricer:AzureOpenAIEndpoint"];
var deploymentName = builder.Configuration["NlxvaPricer:DeploymentName"];
var tenantId = builder.Configuration["NlxvaPricer:TenantId"];
builder.Services.AddAzureOpenAIChatCompletion(
deploymentName: deploymentName,
endpoint: azureEndpoint,
credentials: new DefaultAzureCredential(
new DefaultAzureCredentialOptions { TenantId = tenantId }));
builder.Services.AddKernel();
```
**Expected friction on target:**
- **`az login` not done**: `DefaultAzureCredential` tries multiple auth methods in sequence (environment vars → managed identity → Visual Studio → Azure CLI → etc.). On a developer machine, it relies on Azure CLI. If the developer hasn't run `az login --tenant <tenant-id>`, the error at runtime will be a confusing `CredentialUnavailableException` listing all the methods it tried. The fix is always: `az login --tenant <tenant-id>`.
- **Deployment name vs model name**: In Azure portal, you deploy a model (e.g., `gpt-4o`) and give the deployment a name (e.g., `gpt4o-prod`). You pass the **deployment name** to SK, not the model name. Ask your Azure admin for the deployment name.
- **Azure RBAC permissions**: The developer's Azure AD identity needs the "Cognitive Services OpenAI User" role on the Azure OpenAI resource. Without it, you'll get a 403.
**Verify it works:**
- `dotnet build` succeeds
- At runtime: inject `Kernel` into a test controller and verify it resolves
- Quick smoke test: `kernel.GetRequiredService<IChatCompletionService>()` — should not throw
- Full test: the diagnostic stream-test endpoint (see T6b below)
**If it breaks — diagnostic checklist:**
- Symptom: `CredentialUnavailableException` with "DefaultAzureCredential failed to retrieve a token"
Cause: Developer not logged in to Azure CLI
Fix: Run `az login --tenant <tenant-id>`, then restart CRC.Server
- Symptom: HTTP 403 Forbidden from Azure OpenAI
Cause: Azure AD identity lacks "Cognitive Services OpenAI User" role
Fix: Ask Azure admin to grant the role on the Azure OpenAI resource
- Symptom: HTTP 404 on Azure OpenAI endpoint
Cause: Wrong deployment name, or deployment doesn't exist
Fix: Verify deployment name in Azure portal → Azure OpenAI → Deployments
- Symptom: `InvalidOperationException: No service for type IChatCompletionService`
Cause: `AddAzureOpenAIChatCompletion()` not called before `AddKernel()`
Fix: Ensure registration order: AzureOpenAIChatCompletion first, then Kernel
---
### T6b: Verify streaming hop 1 (Azure OpenAI → CRC.Server)
**Prerequisites:** T6 (SK registered), T7 (controller exists — or add the diagnostic endpoint to any controller temporarily).
**Context:** Before building the full UI, verify that tokens actually stream from Azure OpenAI through CRC.Server. This catches buffering issues early (response compression middleware, Azure API Management, corporate proxies).
**Step-by-step:**
1. Add a temporary diagnostic endpoint to NlxvaPricerController (see Critical Pattern #8 in export-spec)
2. Run: `curl -N https://localhost:7100/api/nlxva-pricer/stream-test`
3. Watch the timestamps in the output
**What correct streaming looks like:**
```
data: [450ms] 1 ← timestamps spread across seconds
data: [620ms]
data: [780ms] 2
data: [950ms]
data: [1100ms] 3
```
**What buffered streaming looks like:**
```
data: [8200ms] 1 ← all timestamps clustered at the end
data: [8201ms]
data: [8202ms] 2
data: [8203ms]
```
**If buffered — check these in order:**
1. **Response compression middleware**: If CRC.Server has `app.UseResponseCompression()`, it buffers SSE to compress. Add `Response.Headers["Content-Encoding"] = "identity";` in the controller to opt out.
2. **Azure API Management (APIM)**: If APIM sits in front of the Azure OpenAI resource, it buffers by default. Need `forward-request` policy with `buffer-response="false"`.
3. **Corporate HTTPS proxy**: Check `echo $HTTPS_PROXY` on the server. May need proxy bypass for `*.openai.azure.com`.
4. **IIS**: If CRC runs under IIS, add `responseBufferLimit="0"` in web.config.
**Always set these headers on SSE endpoints:**
```csharp
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no"; // prevents NGINX buffering
```
5. Remove the diagnostic endpoint after verification.
---
### T7: Add NlxvaPricerController
**Prerequisites:** T2 (DTOs), T4 (ExtractionPlugin), T5 (FewShotService), T6 (SK Kernel).
**Context:** This is the main API surface. Two endpoints, same SSE streaming pattern. The controller is intentionally stateless — all state is per-request.
**Step-by-step:**
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:AzureOpenAIEndpoint` in CRC.Server `appsettings.json` — e.g., `https://your-resource.openai.azure.com/` — **no `/v1`**. What happens if missing: SK registration fails at startup
- [ ] `NlxvaPricer:DeploymentName` in CRC.Server `appsettings.json` — the Azure deployment name (not model name). Get from Azure portal → Azure OpenAI → Deployments
- [ ] `NlxvaPricer:TenantId` in CRC.Server `appsettings.json` — Azure AD tenant ID. Same tenant CRC uses for Microsoft.Identity.Web auth
- [ ] `NlxvaPricer:FewShotPath` in CRC.Server `appsettings.json` — default `examples/extraction`
- [ ] Developer has run `az login --tenant <tenant-id>` — `DefaultAzureCredential` needs this. Failure shows at first LLM call, not at startup
- [ ] `ExternalApis:CounterpartyBaseUrl` — default `http://localhost:5000/api/counterparty` (or use CRC's existing)
- [ ] `ExternalApis:TradeBaseUrl` — default `http://localhost:5000/api/trade` (or use CRC's existing)
- [ ] `ExternalApis:CurrencyBaseUrl` — default `http://localhost:5000/api/currency` (or use CRC's existing)
- [ ] CRC.Client `appsettings.json` or `wwwroot/appsettings.json` — API base URL
- [ ] CORS policy in CRC.Server — verify CRC.Client origin is allowed
- [ ] `examples/` folder exists at configured path with `instruction-template.txt` and `few-shot/` subdirectories
---
### T15: Smoke test
**Full verification sequence:**
1. `dotnet build --configuration release CRC.sln` — 0 errors, 0 new warnings
2. Ensure developer has run `az login --tenant <tenant-id>`
3. Start CRC.Server
4. Navigate to CRC.Client in browser
5. Verify "NL XVA Pricer" appears in sidebar
6. Click it → should navigate to `/nlxva-pricer`
7. Type "Hello" → should see streaming response
8. Switch to System Prompt tab → should see default prompt
9. Switch to Model Settings tab → should see Temperature/TopP/MaxTokens fields
10. Switch back to Chat → conversation should still be there (KeepPanelsAlive)
11. Drop an example email .html → should enter Extraction Mode, see streaming extraction
12. If extraction produces counterparty disambiguation → respond with a number → should route to extract endpoint
13. Click New Chat → everything resets, Extraction Mode indicator gone
---
## Troubleshooting Reference
| # | Symptom | Likely Cause | Fix |
|---|---|---|---|
| 1 | 404 on Azure OpenAI endpoint | Wrong deployment name or endpoint URL | Verify deployment name in Azure portal; endpoint should be `https://<resource>.openai.azure.com/` with NO `/v1` |
| 2 | CORS 403 in browser console | CORS policy doesn't cover CRC.Client origin or `text/event-stream` | Add CRC.Client origin with `AllowAnyHeader()` in CORS config |
| 3 | No streaming — entire response at once | `SetBrowserResponseStreamingEnabled(true)` missing on client | Add to HttpRequestMessage before SendAsync |
| 4 | `NotSupportedException: Synchronous operations` | Using `reader.EndOfStream` in WASM | Replace with `while ((line = await ReadLineAsync()) != null)` |
| 5 | LLM never calls validation tools | Plugin not imported, or `FunctionChoiceBehavior.Auto()` not set | Import plugin per-request in controller action; set Auto() in settings |
| 6 | `InvalidOperationException: Unable to resolve ExtractionPlugin` | Not registered in DI, or lifetime mismatch | Add `AddScoped<ExtractionPlugin>()` to DI; verify dependencies |
| 7 | Markdown not rendering (raw text) | MarkdownService not registered or not injected | Add `AddSingleton<MarkdownService>()` to client DI |
| 8 | XSS in rendered content | Sanitizer not applied, or using raw `MarkupString` without sanitization | Ensure ConvertToHtml is called (includes sanitization) before MarkupString |
| 9 | CSS overflow — chat extends below viewport | Wrong AppBar height in calc (48px vs 64px) | Change `calc(100vh - 48px)` to `calc(100vh - 64px)` |
| 10 | Tab content resets on switch | MudBlazor version missing `KeepPanelsAlive` | Upgrade MudBlazor or use state fields (already done in code pattern) |
| 11 | `FileNotFoundException` for instruction-template.txt | Examples folder not at ContentRootPath | Log ContentRootPath; verify examples location; update FewShotPath config |
| 12 | Empty few-shot examples (only system message) | Subdirectory structure wrong | Verify `examples/extraction/few-shot/01/input.html` exists |
| 13 | `NuGet restore error` for SemanticKernel | Package not in GV Artifactory feed | Request mirroring or temporary nuget.org source |
| 14 | `CredentialUnavailableException` from DefaultAzureCredential | Developer not logged in via Azure CLI | Run `az login --tenant <tenant-id>`, restart CRC.Server |
| 14b | HTTP 403 from Azure OpenAI | Azure AD identity lacks role | Grant "Cognitive Services OpenAI User" on the Azure OpenAI resource |
| 14c | All tokens arrive at once (no streaming) | Response compression or proxy buffering | Use stream-test diagnostic endpoint; check `UseResponseCompression()`; set `X-Accel-Buffering: no` header |
| 14d | Streaming works in curl but not in browser | Response compression only applied for browser Accept-Encoding | Add `Response.Headers["Content-Encoding"] = "identity"` in SSE endpoints |
| 15 | Drag-drop file not triggering extraction | `file-drop.js` not loaded | Check `<script>` tag in index.html; check browser console for JS errors |
| 16 | `window.fileDrop is undefined` | Script loaded after Blazor framework init | Move `<script>` tag before `_framework/blazor.webassembly.js` |
| 17 | `JsonException` when parsing SSE data | SSE line doesn't match expected format | Add logging for raw SSE lines; check server-side WriteSSEAsync format |
---
## Dependency & Package Notes
### Microsoft.SemanticKernel
- **Why needed:** AI orchestration — chat completion, tool calling, auto function invocation
- **.NET compatibility:** Requires .NET 8+ (compatible with CRC)
- **Transitive dependencies:** Pulls in `Microsoft.Extensions.DependencyInjection`, `Microsoft.Extensions.Logging`, `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
### Microsoft.SemanticKernel.Connectors.AzureOpenAI
- **Why needed:** Azure OpenAI-specific connector for SK (provides `AddAzureOpenAIChatCompletion()`)
- **.NET compatibility:** Same as core SK package
- **Transitive dependencies:** Pulls in `Azure.AI.OpenAI` SDK
- **NuGet source:** Same as core SK — nuget.org
- **Note:** This is separate from the core SK package. Without it, only `AddOpenAIChatCompletion()` is available (for non-Azure endpoints).
### Azure.Identity
- **Why needed:** Provides `DefaultAzureCredential` for Azure AD authentication to Azure OpenAI
- **.NET compatibility:** .NET Standard 2.0+ (compatible with everything)
- **CRC likely already has this** — it uses `Microsoft.Identity.Web` for Azure AD auth. Check `grep Azure.Identity CRC.Server.csproj`.
- **Version conflicts:** If CRC has an older version, SK may pull in a newer one. Usually compatible, but verify with `dotnet build`.
- **NuGet source:** Available on nuget.org and commonly mirrored in enterprise feeds
### Markdig (1.1.1)
- **Why needed:** Markdown → HTML conversion for rendering LLM responses
- **.NET compatibility:** .NET Standard 2.0+ (compatible with everything)
- **Transitive dependencies:** None
- **NuGet source:** Available on nuget.org and commonly mirrored
- **Size:** ~500KB
- **Conflicts:** None known
---
## Rollback Plan
If the feature needs to be removed:
**Files added (safe to delete):**
- `CRC.Server/Controllers/NlxvaPricerController.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
- `Microsoft.SemanticKernel.Connectors.AzureOpenAI` from CRC.Server
- `Azure.Identity` from CRC.Server (only if not used by other CRC features — likely IS used, so leave it)
- `Markdig` from CRC.Client (if not used by other features)
**Config keys to remove:**
- `NlxvaPricer:*` section (AzureOpenAIEndpoint, DeploymentName, TenantId, FewShotPath) from `appsettings.json`
- `ExternalApis:*` section (if only used by this feature)

View File

@@ -0,0 +1,590 @@
# 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)
- `Microsoft.SemanticKernel.Connectors.AzureOpenAI` (for Azure OpenAI connector)
- `Azure.Identity` (for `DefaultAzureCredential` — CRC may already have this)
- `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 → Azure OpenAI (via DefaultAzureCredential)
│ 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": {
"AzureOpenAIEndpoint": "https://your-resource.openai.azure.com/",
"DeploymentName": "gpt4o-prod",
"TenantId": "<your-azure-ad-tenant-id>",
"FewShotPath": "examples/extraction"
},
"ExternalApis": {
"CounterpartyBaseUrl": "http://localhost:5000/api/counterparty",
"TradeBaseUrl": "http://localhost:5000/api/trade",
"CurrencyBaseUrl": "http://localhost:5000/api/currency"
}
}
```
If using API key auth instead of Azure AD, replace `TenantId` with:
```json
"ApiKey": "<your-azure-openai-api-key>"
```
## 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. Azure OpenAI: use deployment name, NOT model name; NO `/v1` suffix
**Why:** Azure OpenAI uses `AddAzureOpenAIChatCompletion()`, not `AddOpenAIChatCompletion()`.
The endpoint is your Azure resource URL (no `/v1` — the Azure SDK constructs the path internally).
The `deploymentName` is the name you gave the deployment in Azure portal, not the model name.
Auth uses `DefaultAzureCredential` with the tenant ID, not an API key.
```csharp
using Azure.Identity;
builder.Services.AddAzureOpenAIChatCompletion(
deploymentName: builder.Configuration["NlxvaPricer:DeploymentName"] ?? "gpt4o-prod",
endpoint: builder.Configuration["NlxvaPricer:AzureOpenAIEndpoint"]
?? "https://your-resource.openai.azure.com/",
credentials: new DefaultAzureCredential(
new DefaultAzureCredentialOptions
{
TenantId = builder.Configuration["NlxvaPricer:TenantId"]
}));
```
If using API key instead of Azure AD:
```csharp
builder.Services.AddAzureOpenAIChatCompletion(
deploymentName: "gpt4o-prod",
endpoint: "https://your-resource.openai.azure.com/",
apiKey: builder.Configuration["NlxvaPricer:ApiKey"]);
```
### 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;
```
### 8. SSE response buffering — verify both streaming hops
**Why:** The architecture has two streaming hops: Azure OpenAI → CRC.Server → Browser.
If anything buffers in either hop, the user sees no tokens until the full response completes.
Common buffers: response compression middleware, reverse proxies (NGINX/IIS), Azure API Management.
**Diagnostic endpoint (add temporarily, remove after verifying):**
```csharp
[HttpGet("stream-test")]
public async Task StreamTest()
{
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no"; // NGINX hint
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory();
history.AddUserMessage("Count from 1 to 10, one number per line.");
var sw = System.Diagnostics.Stopwatch.StartNew();
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(history))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
await Response.WriteAsync($"data: [{sw.ElapsedMilliseconds}ms] {chunk.Content}\n\n");
await Response.Body.FlushAsync();
}
}
await Response.WriteAsync("data: [DONE]\n\n");
}
```
**Test with:** `curl -N https://localhost:7100/api/nlxva-pricer/stream-test`
- Timestamps spread over seconds = streaming works
- All timestamps clustered at the end = something is buffering
**If CRC.Server uses `UseResponseCompression()`, exclude SSE:**
```csharp
Response.Headers["Content-Encoding"] = "identity"; // opt out per-response
```
**Response headers to always set on SSE endpoints:**
```csharp
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no"; // prevents NGINX buffering
```
## Wiring
### CRC.Server DI registration order (add to existing Program.cs / Startup.cs)
```csharp
// 1. Semantic Kernel — Azure OpenAI connector with Azure AD auth
using Azure.Identity;
var azureEndpoint = builder.Configuration["NlxvaPricer:AzureOpenAIEndpoint"]
?? "https://your-resource.openai.azure.com/";
var deploymentName = builder.Configuration["NlxvaPricer:DeploymentName"] ?? "gpt4o-prod";
var tenantId = builder.Configuration["NlxvaPricer:TenantId"];
builder.Services.AddAzureOpenAIChatCompletion(
deploymentName: deploymentName,
endpoint: azureEndpoint,
credentials: new DefaultAzureCredential(
new DefaultAzureCredentialOptions { TenantId = tenantId }));
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

@@ -0,0 +1,85 @@
## Purpose
Define the autonomous agent-driven extraction pipeline — structured field extraction from natural language, schema-based validation via tool calling, autonomous retry logic, and human-in-the-loop clarification.
## Requirements
### Requirement: Structured field extraction from natural language
The agent SHALL extract a predefined set of key-value pairs from user-provided natural language text (e.g., email content) and return them as a structured JSON object.
#### Scenario: All fields extracted successfully
- **WHEN** the user sends a message containing natural language with all required information
- **THEN** the agent returns a JSON object with all predefined fields populated from the text
#### Scenario: Partial extraction
- **WHEN** the user sends a message that contains some but not all required fields
- **THEN** the agent extracts available fields and leaves missing fields as null
### 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: 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.
#### Scenario: Agent retries and succeeds
- **WHEN** validation fails on the first attempt but the error is recoverable
- **THEN** the agent retries extraction and calls validation again, up to 3 total attempts
#### Scenario: Agent exhausts retries and escalates
- **WHEN** validation fails after 3 attempts
- **THEN** the agent sends a natural language message to the user identifying the specific fields it could not resolve and asking for clarification
### 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,70 @@
## Purpose
Define the streaming AI response pipeline — backend chat endpoint using Semantic Kernel, SSE delivery to the WASM client, configuration, and error handling.
## 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
### Requirement: Streaming response delivery
The API backend SHALL stream the Semantic Kernel's chat completion response back to the WASM client as `text/event-stream`, forwarding text content so the client can render tokens incrementally. The SSE event format MUST remain `data: {"text":"..."}\n\n` for text deltas and `data: [DONE]\n\n` for completion.
#### Scenario: Tokens stream to client
- **WHEN** the Semantic Kernel emits streaming chat message content
- **THEN** the backend forwards each content chunk as an SSE event to the client containing the text fragment
#### Scenario: Stream completes
- **WHEN** the Semantic Kernel streaming response completes
- **THEN** the backend signals stream completion to the client with `data: [DONE]\n\n`
### Requirement: Configurable proxy target
The CLIProxyAPI base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded. These values are used to configure the Semantic Kernel OpenAI connector.
#### Scenario: Configuration read at startup
- **WHEN** the API starts
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration to configure the Semantic Kernel
### Requirement: Client streams from backend
The WASM client SHALL call `POST /api/chat` with `SetBrowserResponseStreamingEnabled(true)` and `HttpCompletionOption.ResponseHeadersRead`, then iterate the SSE stream to update the UI token by token.
#### Scenario: Client reads streaming response
- **WHEN** the client sends a chat request
- **THEN** it reads the response stream incrementally and appends each text delta to the assistant message in real time
### Requirement: Error propagation
If the LLM service returns an error or is unreachable, the API backend SHALL return an error SSE event and the client SHALL display the error to the user.
#### Scenario: LLM service unreachable
- **WHEN** the CLIProxyAPI proxy is not running
- **THEN** the client displays an error message instead of an assistant response

View File

@@ -6,17 +6,17 @@ Define the chat interface — message display, input handling, auto-scroll, and
### Requirement: Message display ### Requirement: Message display
The chat page SHALL display messages in a vertically scrolling list, with each message showing the sender role (user or assistant), the message content, and a visual distinction between user and assistant messages (e.g., alignment, color, or avatar). The chat page SHALL display messages in a vertically scrolling list, with each message showing the sender role (user or assistant), the message content, and a visual distinction between user and assistant messages (e.g., alignment, color, or avatar). Assistant messages SHALL render content as formatted HTML converted from markdown; user messages SHALL display as plain text.
#### Scenario: User message displayed #### Scenario: User message displayed
- **WHEN** the user sends a message - **WHEN** the user sends a message
- **THEN** the message appears in the message list aligned or styled to indicate it is from the user - **THEN** the message appears in the message list aligned or styled to indicate it is from the user, rendered as plain text
#### Scenario: Assistant message displayed #### Scenario: Assistant message displayed
- **WHEN** the assistant responds - **WHEN** the assistant responds
- **THEN** the response appears in the message list with distinct styling from user messages (different alignment, color, or avatar) - **THEN** the response appears in the message list with distinct styling from user messages, with markdown content rendered as formatted HTML
#### Scenario: Message ordering #### Scenario: Message ordering
@@ -25,7 +25,7 @@ The chat page SHALL display messages in a vertically scrolling list, with each m
### Requirement: Message input ### 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 #### Scenario: Submit via button
@@ -42,14 +42,57 @@ The chat page SHALL provide a text input area at the bottom of the page where th
- **WHEN** the user attempts to send an empty or whitespace-only message - **WHEN** the user attempts to send an empty or whitespace-only message
- **THEN** nothing is sent and no message is added - **THEN** nothing is sent and no message is added
### Requirement: Hardcoded response #### Scenario: Input disabled during streaming
In this phase, the assistant SHALL reply with a hardcoded message to every user input. This stubs the AI integration point for future phases. - **WHEN** the assistant is currently streaming a response
- **THEN** the input field, send button, and upload button are disabled until streaming completes
#### Scenario: Bot replies to any input #### 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
The chat page SHALL show a visual indicator while waiting for the first token from the assistant.
#### Scenario: Indicator shown during wait
- **WHEN** the user sends a message and the assistant has not yet started streaming
- **THEN** a thinking indicator (e.g., animated dots) is shown in the assistant message area
#### Scenario: Indicator replaced by content
- **WHEN** the first token arrives from the stream
- **THEN** the thinking indicator is replaced by the streamed text
### Requirement: Streaming AI response
The assistant SHALL reply with a real AI response streamed from the backend API, using the full conversation history as context. Tokens appear incrementally as they arrive.
#### Scenario: Bot replies with streamed AI response
- **WHEN** the user sends any message - **WHEN** the user sends any message
- **THEN** the assistant replies with a hardcoded response (e.g., "This is a placeholder response. AI integration coming soon!") - **THEN** the assistant message appears and grows token by token as the stream delivers text
#### Scenario: Full history sent with each request
- **WHEN** the user sends a message after prior exchanges
- **THEN** all previous user and assistant messages are included in the API request so the AI has conversational context
### Requirement: New chat button
The chat page SHALL provide a button to clear the current conversation and start a new one.
#### Scenario: User starts a new chat
- **WHEN** the user clicks the "New Chat" button
- **THEN** all messages are cleared and the empty state is shown
#### Scenario: New chat button disabled during streaming
- **WHEN** the assistant is currently streaming a response
- **THEN** the "New Chat" button is disabled
### Requirement: Auto-scroll ### Requirement: Auto-scroll
@@ -62,9 +105,24 @@ The message list SHALL automatically scroll to the newest message when a new mes
### Requirement: Chat page is default route ### 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 - **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,95 @@
## Purpose
Define rich text rendering for assistant messages — markdown-to-HTML conversion, sanitization, styling, and streaming compatibility.
## Requirements
### Requirement: Markdown rendering for assistant messages
The system SHALL convert assistant message content from markdown to formatted HTML using Markdig, displaying headings, bold, italic, code blocks, lists, tables, links, and blockquotes with proper visual formatting.
#### Scenario: Markdown bold and italic rendered
- **WHEN** an assistant message contains `**bold**` or `*italic*` text
- **THEN** the text is displayed with bold or italic formatting respectively
#### Scenario: Code block rendered
- **WHEN** an assistant message contains a fenced code block (triple backticks)
- **THEN** the code is displayed in a monospace font within a visually distinct block
#### Scenario: Inline code rendered
- **WHEN** an assistant message contains inline code (single backticks)
- **THEN** the code is displayed in a monospace font with a subtle background
#### Scenario: List rendered
- **WHEN** an assistant message contains a markdown list (ordered or unordered)
- **THEN** the list is displayed with proper indentation and bullet/number markers
#### Scenario: Heading rendered
- **WHEN** an assistant message contains markdown headings (# through ######)
- **THEN** the headings are displayed with appropriate size and weight
#### Scenario: Link rendered
- **WHEN** an assistant message contains a markdown link `[text](url)`
- **THEN** the link is displayed as a clickable hyperlink opening in a new tab
#### Scenario: Table rendered
- **WHEN** an assistant message contains a markdown table
- **THEN** the table is displayed with borders, header row styling, and proper alignment
### Requirement: HTML sanitization
The system SHALL sanitize all HTML output from the markdown renderer to prevent cross-site scripting (XSS) attacks from LLM-generated content.
#### Scenario: Script tags stripped
- **WHEN** assistant message content contains `<script>` tags
- **THEN** the script tags and their content are removed from the rendered output
#### Scenario: Event handlers stripped
- **WHEN** assistant message content contains HTML attributes like `onclick` or `onerror`
- **THEN** the attributes are removed from the rendered output
#### Scenario: Safe tags preserved
- **WHEN** assistant message content contains allowed structural HTML (p, strong, em, code, pre, ul, ol, li, a, table elements, blockquote, br, h1-h6)
- **THEN** the tags are preserved in the rendered output
### Requirement: Markdown styling within chat bubbles
The system SHALL style rendered markdown elements to be visually consistent with the MudBlazor chat bubble theme.
#### Scenario: Code block styled in bubble
- **WHEN** a code block is rendered inside an assistant chat bubble
- **THEN** it has a distinct background color, padding, border-radius, and does not overflow the bubble width (horizontal scroll if needed)
#### Scenario: Paragraph spacing in bubble
- **WHEN** multiple paragraphs are rendered inside an assistant chat bubble
- **THEN** paragraphs have appropriate spacing without excessive gaps
### Requirement: Streaming compatibility
The system SHALL re-render markdown as new tokens arrive during streaming without visual glitches.
#### Scenario: Partial markdown rendered during streaming
- **WHEN** tokens are arriving and the current content contains incomplete markdown (e.g., a code block not yet closed)
- **THEN** the content is rendered with best-effort formatting and updates smoothly as more tokens complete the markdown structure
### Requirement: User messages remain plain text
The system SHALL continue to render user messages as plain text without markdown processing.
#### Scenario: User message not processed as markdown
- **WHEN** a user message contains markdown syntax like `**bold**`
- **THEN** the raw text `**bold**` is displayed, not formatted bold text

View File

@@ -0,0 +1,46 @@
## Purpose
Define the Semantic Kernel integration layer — kernel registration, OpenAI connector configuration, plugin registration, and automatic function calling.
## Requirements
### Requirement: Semantic Kernel service registration
The API backend SHALL register a Semantic Kernel `Kernel` instance in the ASP.NET Core DI container at startup, configured with an OpenAI chat completion connector.
#### Scenario: Kernel registered at startup
- **WHEN** the API application starts
- **THEN** a `Kernel` instance is available for injection into controllers
### Requirement: OpenAI connector targets CLIProxyAPI proxy
The Semantic Kernel OpenAI chat completion service SHALL be configured to use the existing CLIProxyAPI proxy endpoint as its base URL, reading the URL and model name from `appsettings.json`.
#### Scenario: Connector uses configured endpoint
- **WHEN** the kernel makes a chat completion request
- **THEN** it sends the request to the URL specified in `ResponsesApi:BaseUrl` configuration
#### Scenario: Model from configuration
- **WHEN** the kernel makes a chat completion request
- **THEN** it uses the model name specified in `ResponsesApi:Model` configuration
### Requirement: Plugin registration
The API backend SHALL register extraction and validation plugins with the Kernel so they are available as tools for the LLM to invoke.
#### Scenario: Plugins available as tools
- **WHEN** the kernel is constructed
- **THEN** all registered plugin functions appear in the tool list sent to the LLM
### Requirement: Auto function calling
The Kernel SHALL be configured with automatic function calling enabled, allowing the LLM to invoke registered plugin functions without manual dispatch code.
#### Scenario: LLM invokes tool automatically
- **WHEN** the LLM decides to call a registered function during chat completion
- **THEN** the kernel automatically executes the function and returns the result to the LLM

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

Some files were not shown because too many files have changed in this diff Show More