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

Subject: RE: AG – Inflation swaps – CVA Request

+

Ovi,

+

Hope you are well.

+

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&P and A1 by Moody's please?

+

OB 27/11/2025

+

Swap 1 – please price standalone

+ + + + +
CSAMurexPV (£)
Coupon LegBTMU_JPY793530834,562,456
APD legBTMU_JPY7935308476,985,170
+

Total PV: 81,547,626

+

Swap 2 – please price standalone

+ + + + +
CSAMurexPV (£)
Coupon LegBTMU_JPY793530931,663,261
APD legBTMU_JPY7935309441,333,773
+

Total PV: 42,997,034

+

Many thanks.

+

Kind regards,

+ + diff --git a/examples/extraction/few-shot/01/output.json b/examples/extraction/few-shot/01/output.json new file mode 100644 index 0000000..0fed7c1 --- /dev/null +++ b/examples/extraction/few-shot/01/output.json @@ -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" + } + ] +} diff --git a/examples/extraction/few-shot/02/input.html b/examples/extraction/few-shot/02/input.html new file mode 100644 index 0000000..b64a144 --- /dev/null +++ b/examples/extraction/few-shot/02/input.html @@ -0,0 +1,14 @@ + + +

Subject: CVA quote – USD interest rate swap

+

Hi team,

+

Please provide CVA for the following single interest rate swap with Deutsche Bank AG, London Branch.

+

Value date: 15/03/2026

+ + + +
CSAMurexPV ($)
Fixed LegDB_USD8120045112,750,000
+

Thanks,

+

Sarah

+ + diff --git a/examples/extraction/few-shot/02/output.json b/examples/extraction/few-shot/02/output.json new file mode 100644 index 0000000..9256104 --- /dev/null +++ b/examples/extraction/few-shot/02/output.json @@ -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" + } + ] +} diff --git a/examples/extraction/few-shot/03/input.html b/examples/extraction/few-shot/03/input.html new file mode 100644 index 0000000..a62d02b --- /dev/null +++ b/examples/extraction/few-shot/03/input.html @@ -0,0 +1,15 @@ + + +

Subject: RE: CVA – Cross-currency swap with break clause

+

Dear Ovi,

+

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.

+

OB 01/06/2025

+ + + + +
CSAMurexPV (€)
EUR LegBARC_EUR778901128,421,300
GBP LegBARC_EUR7789011322,105,800
+

Thanks,

+

Mark

+ + diff --git a/examples/extraction/few-shot/03/output.json b/examples/extraction/few-shot/03/output.json new file mode 100644 index 0000000..bbdd93b --- /dev/null +++ b/examples/extraction/few-shot/03/output.json @@ -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" + } + ] +} diff --git a/examples/extraction/instruction-template.txt b/examples/extraction/instruction-template.txt new file mode 100644 index 0000000..962be9a --- /dev/null +++ b/examples/extraction/instruction-template.txt @@ -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. \ No newline at end of file diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/.openspec.yaml b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/.openspec.yaml new file mode 100644 index 0000000..c551aea --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/design.md b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/design.md new file mode 100644 index 0000000..56ebe70 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/design.md @@ -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 `/`. diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/proposal.md b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/proposal.md new file mode 100644 index 0000000..4e9a5e1 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/chat-ui/spec.md b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/chat-ui/spec.md new file mode 100644 index 0000000..039f466 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/chat-ui/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/sidebar-navigation/spec.md b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/sidebar-navigation/spec.md new file mode 100644 index 0000000..095fb4d --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/specs/sidebar-navigation/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-add-sidebar-navigation/tasks.md b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/tasks.md new file mode 100644 index 0000000..8a89fe0 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-sidebar-navigation/tasks.md @@ -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 `` 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 diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/.openspec.yaml b/openspec/changes/archive/2026-04-06-email-upload-ux/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/design.md b/openspec/changes/archive/2026-04-06-email-upload-ux/design.md new file mode 100644 index 0000000..1bfb0dc --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/design.md @@ -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. diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/proposal.md b/openspec/changes/archive/2026-04-06-email-upload-ux/proposal.md new file mode 100644 index 0000000..4ba7d8b --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/proposal.md @@ -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) diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/specs/chat-ui/spec.md b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/chat-ui/spec.md new file mode 100644 index 0000000..79c5dce --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/chat-ui/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/specs/email-upload/spec.md b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/email-upload/spec.md new file mode 100644 index 0000000..67ab222 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/email-upload/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/specs/extraction-conversation-flow/spec.md b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/extraction-conversation-flow/spec.md new file mode 100644 index 0000000..0f73d69 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/specs/extraction-conversation-flow/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-email-upload-ux/tasks.md b/openspec/changes/archive/2026-04-06-email-upload-ux/tasks.md new file mode 100644 index 0000000..a40d9b7 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-email-upload-ux/tasks.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/.openspec.yaml b/openspec/changes/archive/2026-04-06-expose-prompt-settings/.openspec.yaml new file mode 100644 index 0000000..c551aea --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/design.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/design.md new file mode 100644 index 0000000..cccd063 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/design.md @@ -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.0–2.0), TopP (0.0–1.0), MaxTokens (1–4096) + +### 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. diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/proposal.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/proposal.md new file mode 100644 index 0000000..c9b29a3 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-streaming/spec.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-streaming/spec.md new file mode 100644 index 0000000..8767bd7 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-streaming/spec.md @@ -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) diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-ui/spec.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-ui/spec.md new file mode 100644 index 0000000..8639000 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/chat-ui/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-api/spec.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-api/spec.md new file mode 100644 index 0000000..04841d2 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-api/spec.md @@ -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) diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-ui/spec.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-ui/spec.md new file mode 100644 index 0000000..aa46047 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/specs/prompt-settings-ui/spec.md @@ -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.0–2.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.0–1.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 1–4096 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 diff --git a/openspec/changes/archive/2026-04-06-expose-prompt-settings/tasks.md b/openspec/changes/archive/2026-04-06-expose-prompt-settings/tasks.md new file mode 100644 index 0000000..5bbff8a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-expose-prompt-settings/tasks.md @@ -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 `` with three `` 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` for Temperature (min 0.0, max 2.0, step 0.1) with label and helper text +- [x] 5.2 Add `MudNumericField` for TopP (min 0.0, max 1.0, step 0.1) with label and helper text +- [x] 5.3 Add `MudNumericField` 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 diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/.openspec.yaml b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/design.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/design.md new file mode 100644 index 0000000..fc548c3 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/design.md @@ -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. diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/proposal.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/proposal.md new file mode 100644 index 0000000..cb17c94 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/proposal.md @@ -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) diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/chat-streaming/spec.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/chat-streaming/spec.md new file mode 100644 index 0000000..2a80316 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/chat-streaming/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/extraction-endpoint/spec.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/extraction-endpoint/spec.md new file mode 100644 index 0000000..47a3ddd --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/extraction-endpoint/spec.md @@ -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, 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 diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/few-shot-prompting/spec.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/few-shot-prompting/spec.md new file mode 100644 index 0000000..053edd9 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/specs/few-shot-prompting/spec.md @@ -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/` diff --git a/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/tasks.md b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/tasks.md new file mode 100644 index 0000000..8d0891c --- /dev/null +++ b/openspec/changes/archive/2026-04-06-few-shot-prompt-infrastructure/tasks.md @@ -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 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, 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) diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/.openspec.yaml b/openspec/changes/archive/2026-04-06-update-extraction-schema/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/design.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/design.md new file mode 100644 index 0000000..dbac167 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/design.md @@ -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 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` 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()` 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. diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/proposal.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/proposal.md new file mode 100644 index 0000000..3eaae7e --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/agent-extraction/spec.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/agent-extraction/spec.md new file mode 100644 index 0000000..b9edb66 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/agent-extraction/spec.md @@ -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`. 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 diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-schema/spec.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-schema/spec.md new file mode 100644 index 0000000..71e085b --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-schema/spec.md @@ -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 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" diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-tools/spec.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-tools/spec.md new file mode 100644 index 0000000..ad5d323 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/specs/extraction-tools/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-06-update-extraction-schema/tasks.md b/openspec/changes/archive/2026-04-06-update-extraction-schema/tasks.md new file mode 100644 index 0000000..5aeb555 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-update-extraction-schema/tasks.md @@ -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 Items` property +- [x] 1.3 Delete the old `ExtractedFields.cs` +- [x] 1.4 Update `ValidationResult.cs` to support candidate lists — add `List? 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()` 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 diff --git a/openspec/exports/chat-agent-full-openspec.md b/openspec/exports/chat-agent-full-openspec.md new file mode 100644 index 0000000..f02e7c3 --- /dev/null +++ b/openspec/exports/chat-agent-full-openspec.md @@ -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: | + . + 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. + . +``` + +--- + +## 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` 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` 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 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) + +### 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). 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, AddHttpClient +- [ ] 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` diff --git a/openspec/exports/chat-agent-full-spec.md b/openspec/exports/chat-agent-full-spec.md new file mode 100644 index 0000000..22c1a36 --- /dev/null +++ b/openspec/exports/chat-agent-full-spec.md @@ -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 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 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":""}\n\n`, completion: `data: [DONE]\n\n`, error: `data: {"error":""}\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` 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` + - 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 ` + + + diff --git a/src/ChatAgent.Client/wwwroot/js/file-drop.js b/src/ChatAgent.Client/wwwroot/js/file-drop.js new file mode 100644 index 0000000..7ec4ad8 --- /dev/null +++ b/src/ChatAgent.Client/wwwroot/js/file-drop.js @@ -0,0 +1,56 @@ +// file-drop.js -- JavaScript interop for drag-and-drop file handling. +// +// Blazor WASM has limited built-in drag-and-drop support. This JS module +// handles the browser's drag/drop events, reads the dropped file as text, +// and calls back into .NET with the filename and content. +// +// The .NET side registers a DotNetObjectReference that receives callbacks +// for dragenter, dragleave, and file drop events. + +window.fileDrop = { + // Registers drag-and-drop event handlers on the specified element. + // dotNetRef: a DotNetObjectReference for callbacks to .NET + // elementSelector: CSS selector for the drop target element + register: function (dotNetRef, elementSelector) { + const element = document.querySelector(elementSelector); + if (!element) return; + + // Prevent default browser behavior for drag events (which would + // navigate away or open the file). Required for drop to work. + element.addEventListener('dragover', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + element.addEventListener('dragenter', function (e) { + e.preventDefault(); + e.stopPropagation(); + dotNetRef.invokeMethodAsync('OnDragEnter'); + }); + + element.addEventListener('dragleave', function (e) { + e.preventDefault(); + e.stopPropagation(); + // Only fire if leaving the element itself, not a child + if (e.target === element) { + dotNetRef.invokeMethodAsync('OnDragLeave'); + } + }); + + element.addEventListener('drop', function (e) { + e.preventDefault(); + e.stopPropagation(); + dotNetRef.invokeMethodAsync('OnDragLeave'); + + const files = e.dataTransfer.files; + if (files.length === 0) return; + + const file = files[0]; + const reader = new FileReader(); + reader.onload = function () { + dotNetRef.invokeMethodAsync('OnFileDrop', file.name, reader.result); + }; + reader.readAsText(file); + }); + } +}; diff --git a/src/ChatAgent.Shared/Models/ChatRequest.cs b/src/ChatAgent.Shared/Models/ChatRequest.cs index c8c95e1..1140b79 100644 --- a/src/ChatAgent.Shared/Models/ChatRequest.cs +++ b/src/ChatAgent.Shared/Models/ChatRequest.cs @@ -6,9 +6,8 @@ namespace ChatAgent.Shared.Models { /// - /// Request payload for POST /api/chat. Contains the conversation messages - /// to send to the AI model. Currently single-turn (one user message), - /// but the list structure supports multi-turn in future phases. + /// Request payload for POST /api/chat. Contains the conversation messages, + /// an optional system prompt, and optional model parameter overrides. /// public class ChatRequest { @@ -17,5 +16,18 @@ namespace ChatAgent.Shared.Models /// and Content (the text). The API forwards these to the Responses API. /// public List Messages { get; set; } = new(); + + /// + /// Optional system prompt. When non-empty, the API inserts this as the first + /// system message in the ChatHistory before the conversation messages. + /// System prompts define the AI's persona, behavior, and constraints. + /// + public string? SystemPrompt { get; set; } + + /// + /// Optional model parameter overrides (temperature, top-p, max tokens). + /// Null means use server defaults. Only non-null fields override defaults. + /// + public ModelSettings? Settings { get; set; } } } diff --git a/src/ChatAgent.Shared/Models/ExtractedFields.cs b/src/ChatAgent.Shared/Models/ExtractedFields.cs deleted file mode 100644 index c73ab83..0000000 --- a/src/ChatAgent.Shared/Models/ExtractedFields.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ExtractedFields.cs -- Strongly-typed schema for structured data extraction. -// -// This class defines the predefined set of key-value fields that the AI agent -// extracts from natural language input (e.g., email text). All fields are known -// at compile time. Required fields must be non-null for validation to pass. -// -// Placeholder fields are used until the real schema is provided. - -namespace ChatAgent.Shared.Models -{ - /// - /// The fixed set of fields the agent extracts from natural language input. - /// Required fields are marked with comments; optional fields may be null. - /// - public class ExtractedFields - { - /// Client or company name (required). - public string? Client { get; set; } - - /// Project or engagement name (required). - public string? Project { get; set; } - - /// Number of hours worked (required). - public decimal? Hours { get; set; } - - /// Hourly rate (required). - public decimal? Rate { get; set; } - - /// Currency code, e.g. "USD", "GBP" (required). - public string? Currency { get; set; } - - /// Date of work or service (required). ISO 8601 format preferred. - public string? Date { get; set; } - - /// Description of work performed (optional). - public string? Description { get; set; } - - /// Purchase order number (optional). - public string? PoNumber { get; set; } - } -} diff --git a/src/ChatAgent.Shared/Models/ExtractionRequest.cs b/src/ChatAgent.Shared/Models/ExtractionRequest.cs new file mode 100644 index 0000000..961e7b4 --- /dev/null +++ b/src/ChatAgent.Shared/Models/ExtractionRequest.cs @@ -0,0 +1,28 @@ +// ExtractionRequest.cs -- DTO sent from the WASM client to trigger email extraction. +// +// This lives in ChatAgent.Shared so both client and API agree on the request shape. +// The first request contains only the email HTML. Follow-up requests (e.g., after +// disambiguation) include the growing conversation Messages list. + +namespace ChatAgent.Shared.Models +{ + /// + /// Request payload for POST /api/chat/extract. Contains the email HTML content + /// and optional follow-up conversation messages for disambiguation. + /// + public class ExtractionRequest + { + /// + /// The HTML content of the email to extract trade data from. + /// Required for every extraction request. + /// + public string EmailHtml { get; set; } = string.Empty; + + /// + /// Follow-up conversation messages for disambiguation. + /// Empty on the first request. On follow-ups (e.g., user selecting a counterparty), + /// this contains the full assistant/user exchange since extraction started. + /// + public List Messages { get; set; } = new(); + } +} diff --git a/src/ChatAgent.Shared/Models/ExtractionResult.cs b/src/ChatAgent.Shared/Models/ExtractionResult.cs new file mode 100644 index 0000000..6a448cf --- /dev/null +++ b/src/ChatAgent.Shared/Models/ExtractionResult.cs @@ -0,0 +1,21 @@ +// ExtractionResult.cs -- Wrapper for structured extraction output from a sales email. +// +// A single email may contain multiple swaps with multiple legs, producing +// multiple TradeItem objects. This wrapper collects them into one response. +// The JSON output has the shape: {"items": [{...}, {...}, ...]} + +using System.Text.Json.Serialization; + +namespace ChatAgent.Shared.Models +{ + /// + /// Contains all trade items extracted from a single email. + /// Each trade leg becomes a separate TradeItem in the Items list. + /// + public class ExtractionResult + { + /// The extracted trade items, one per trade leg. + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + } +} diff --git a/src/ChatAgent.Shared/Models/ModelSettings.cs b/src/ChatAgent.Shared/Models/ModelSettings.cs new file mode 100644 index 0000000..71094c2 --- /dev/null +++ b/src/ChatAgent.Shared/Models/ModelSettings.cs @@ -0,0 +1,33 @@ +// ModelSettings.cs -- Optional model parameter overrides sent with chat requests. +// +// All fields are nullable — null means "use the server/model default". +// This allows the client to override only specific parameters without +// needing to know or specify all defaults. + +namespace ChatAgent.Shared.Models +{ + /// + /// Optional model parameters for the AI chat completion request. + /// Null fields are ignored — only non-null values override the defaults. + /// + public class ModelSettings + { + /// + /// Controls randomness in the response. Range: 0.0 (deterministic) to 2.0 (very random). + /// Higher values produce more creative but less predictable output. + /// + public double? Temperature { get; set; } + + /// + /// Nucleus sampling: only tokens in the top P probability mass are considered. + /// Range: 0.0 to 1.0. Lower values focus on more likely tokens. + /// + public double? TopP { get; set; } + + /// + /// Maximum number of tokens to generate in the response. + /// Range: 1 to 4096. Limits response length. + /// + public int? MaxTokens { get; set; } + } +} diff --git a/src/ChatAgent.Shared/Models/TradeItem.cs b/src/ChatAgent.Shared/Models/TradeItem.cs new file mode 100644 index 0000000..e6e9fc2 --- /dev/null +++ b/src/ChatAgent.Shared/Models/TradeItem.cs @@ -0,0 +1,52 @@ +// TradeItem.cs -- Represents a single trade leg extracted from a sales email. +// +// Each email may contain multiple swaps, each with multiple legs (e.g., Coupon Leg, +// APD Leg). Every leg with a unique Murex trade ID becomes a separate TradeItem. +// The extraction agent flattens the nested swap/leg structure into this flat model. +// +// JSON serialization uses snake_case property names via [JsonPropertyName] to match +// the expected output format consumed by downstream systems and external APIs. + +using System.Text.Json.Serialization; + +namespace ChatAgent.Shared.Models +{ + /// + /// A single trade leg extracted from a sales email. + /// All fields except LegalEntity are required for a valid extraction. + /// LegalEntity is populated after counterparty disambiguation via the lookup tool. + /// + public class TradeItem + { + /// Value date in dd/MM/yyyy format, parsed from email date references. + [JsonPropertyName("valuedate")] + public string? Valuedate { get; set; } + + /// Full legal name of the counterparty as stated in the email. + [JsonPropertyName("counterparty")] + public string? Counterparty { get; set; } + + /// + /// Legal entity identifier, populated after counterparty lookup and disambiguation. + /// Null until the user selects from candidate matches returned by the counterparty API. + /// + [JsonPropertyName("legal_entity")] + public string? LegalEntity { get; set; } + + /// Murex trade identifier — each trade leg has a unique ID. + [JsonPropertyName("trade_id")] + public long TradeId { get; set; } + + /// Display currency as ISO code (e.g., "GBP", "USD"). Derived from email currency symbols. + [JsonPropertyName("display_ccy")] + public string? DisplayCcy { get; set; } + + /// Present value as a numeric amount, no formatting (no commas, no currency symbols). + [JsonPropertyName("pv")] + public double Pv { get; set; } + + /// Break clause flag: "Y" or "N". Defaults to "N" if not mentioned in the email. + [JsonPropertyName("breakclause")] + public string? Breakclause { get; set; } + } +} diff --git a/src/ChatAgent.Shared/Models/ValidationResult.cs b/src/ChatAgent.Shared/Models/ValidationResult.cs index e51404c..4a5ed1f 100644 --- a/src/ChatAgent.Shared/Models/ValidationResult.cs +++ b/src/ChatAgent.Shared/Models/ValidationResult.cs @@ -1,24 +1,43 @@ -// ValidationResult.cs -- Result of validating extracted fields. +// ValidationResult.cs -- Result of validating extracted fields or looking up data. // -// Returned by the ExtractionPlugin's validation function so the AI agent -// can see which fields are missing or malformed and decide whether to -// retry extraction or escalate to the user. +// Returned by the ExtractionPlugin's tool functions so the AI agent can see +// whether validation passed, what errors were found, and whether disambiguation +// is needed (e.g., multiple counterparty/legal entity matches). namespace ChatAgent.Shared.Models { /// - /// Describes whether extracted fields passed validation, and if not, - /// which specific errors were found. + /// Describes whether a validation or lookup succeeded, any errors found, + /// and optional candidate matches for disambiguation. /// public class ValidationResult { - /// True if all required fields are present and correctly typed. + /// True if validation passed or lookup returned a single match. public bool IsValid { get; set; } /// - /// List of validation error messages (e.g., "Missing required field: Client"). + /// List of validation error messages (e.g., "Missing required field: counterparty"). /// Empty when IsValid is true. /// public List Errors { get; set; } = new(); + + /// + /// Candidate matches for disambiguation. Non-null when a lookup returns + /// multiple possible matches and the user must select one. + /// The agent presents these to the user as a numbered list. + /// + public List? Candidates { get; set; } + } + + /// + /// A single candidate match returned by a lookup tool (e.g., counterparty search). + /// + public class CandidateMatch + { + /// The display name of the candidate (e.g., "Assured Guaranty UK Ltd"). + public string Name { get; set; } = string.Empty; + + /// The legal entity identifier (e.g., "AG-UK-001"). + public string LegalEntity { get; set; } = string.Empty; } } diff --git a/tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs b/tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs index 6f20f71..0e47966 100644 --- a/tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs +++ b/tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs @@ -1,27 +1,96 @@ +// ExtractionPluginTests.cs -- Tests for the extraction plugin's schema validation +// and external API tool methods. +// +// ValidateSchema is tested directly (no external APIs needed). +// LookupCounterparty, ValidateTrade, and ValidateCurrency are tested with +// mocked HttpClients using MockHttpMessageHandler to simulate API responses. + +using System.Net; +using System.Net.Http.Json; using System.Text.Json; using ChatAgent.Api.Plugins; +using ChatAgent.Api.Services; using ChatAgent.Shared.Models; +using Moq; +using Moq.Protected; namespace ChatAgent.Api.Tests; public class ExtractionPluginTests { - private readonly ExtractionPlugin _plugin = new(); + // Helper to create an ExtractionPlugin with mocked HttpClients + private static ExtractionPlugin CreatePluginWithMocks( + HttpMessageHandler? counterpartyHandler = null, + HttpMessageHandler? tradeHandler = null, + HttpMessageHandler? currencyHandler = null) + { + var counterpartyClient = new CounterpartyApiClient( + new HttpClient(counterpartyHandler ?? CreateOkHandler("[]")) + { BaseAddress = new Uri("http://test/") }); + + var tradeClient = new TradeApiClient( + new HttpClient(tradeHandler ?? CreateOkHandler("{\"isValid\":true}")) + { BaseAddress = new Uri("http://test/") }); + + var currencyClient = new CurrencyApiClient( + new HttpClient(currencyHandler ?? CreateOkHandler("{\"isValid\":true}")) + { BaseAddress = new Uri("http://test/") }); + + return new ExtractionPlugin(counterpartyClient, tradeClient, currencyClient); + } + + // Creates an HttpMessageHandler that returns a 200 OK with the given JSON body + private static HttpMessageHandler CreateOkHandler(string jsonBody) + { + var mock = new Mock(); + mock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json") + }); + return mock.Object; + } + + // Creates an HttpMessageHandler that throws HttpRequestException + private static HttpMessageHandler CreateErrorHandler() + { + var mock = new Mock(); + mock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection refused")); + return mock.Object; + } + + // --- ValidateSchema tests --- [Fact] - public void ValidateExtractedFields_AllRequiredPresent_ReturnsValid() + public void ValidateSchema_ValidResult_ReturnsValid() { - var fields = new ExtractedFields + var plugin = CreatePluginWithMocks(); + var extraction = new ExtractionResult { - Client = "Acme Corp", - Project = "Phase 2", - Hours = 3, - Rate = 150, - Currency = "USD", - Date = "2026-04-01" + Items = new List + { + new() + { + Valuedate = "27/11/2025", + Counterparty = "Assured Guaranty UK Limited", + TradeId = 79353083, + DisplayCcy = "GBP", + Pv = 4562456.0, + Breakclause = "N" + } + } }; - var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields)); + var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction)); var result = JsonSerializer.Deserialize(resultJson); Assert.NotNull(result); @@ -30,30 +99,66 @@ public class ExtractionPluginTests } [Fact] - public void ValidateExtractedFields_MissingRequired_ReturnsErrors() + public void ValidateSchema_MissingFields_ReturnsErrors() { - // Missing Client and Hours - var fields = new ExtractedFields + var plugin = CreatePluginWithMocks(); + // Missing counterparty and display_ccy + var extraction = new ExtractionResult { - Project = "Phase 2", - Rate = 150, - Currency = "USD", - Date = "2026-04-01" + Items = new List + { + new() + { + Valuedate = "27/11/2025", + TradeId = 79353083, + Pv = 4562456.0, + Breakclause = "N" + } + } }; - var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields)); + var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction)); var result = JsonSerializer.Deserialize(resultJson); Assert.NotNull(result); Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.Contains("Client")); - Assert.Contains(result.Errors, e => e.Contains("Hours")); + Assert.Contains(result.Errors, e => e.Contains("counterparty")); + Assert.Contains(result.Errors, e => e.Contains("display_ccy")); } [Fact] - public void ValidateExtractedFields_InvalidJson_ReturnsError() + public void ValidateSchema_InvalidBreakclause_ReturnsError() { - var resultJson = _plugin.ValidateExtractedFields("not valid json"); + var plugin = CreatePluginWithMocks(); + var extraction = new ExtractionResult + { + Items = new List + { + new() + { + Valuedate = "27/11/2025", + Counterparty = "Test Corp", + TradeId = 12345, + DisplayCcy = "GBP", + Pv = 100.0, + Breakclause = "Maybe" + } + } + }; + + var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction)); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("'Y' or 'N'")); + } + + [Fact] + public void ValidateSchema_InvalidJson_ReturnsError() + { + var plugin = CreatePluginWithMocks(); + var resultJson = plugin.ValidateSchema("not valid json"); var result = JsonSerializer.Deserialize(resultJson); Assert.NotNull(result); @@ -62,45 +167,152 @@ public class ExtractionPluginTests } [Fact] - public void ValidateExtractedFields_ZeroHours_ReturnsError() + public void ValidateSchema_EmptyItems_ReturnsError() { - var fields = new ExtractedFields - { - Client = "Acme Corp", - Project = "Phase 2", - Hours = 0, - Rate = 150, - Currency = "USD", - Date = "2026-04-01" - }; - - var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields)); + var plugin = CreatePluginWithMocks(); + var resultJson = plugin.ValidateSchema("{\"items\":[]}"); var result = JsonSerializer.Deserialize(resultJson); Assert.NotNull(result); Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.Contains("Hours")); + Assert.Contains(result.Errors, e => e.Contains("no items")); + } + + // --- LookupCounterparty tests --- + + [Fact] + public async Task LookupCounterparty_SingleMatch_ReturnsValid() + { + var candidates = new List + { + new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" } + }; + var handler = CreateOkHandler(JsonSerializer.Serialize(candidates)); + var plugin = CreatePluginWithMocks(counterpartyHandler: handler); + + var resultJson = await plugin.LookupCounterparty("Assured Guaranty"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.True(result.IsValid); + Assert.NotNull(result.Candidates); + Assert.Single(result.Candidates); } [Fact] - public void ValidateExtractedFields_OptionalFieldsMissing_StillValid() + public async Task LookupCounterparty_MultipleMatches_ReturnsInvalidWithCandidates() { - // Description and PoNumber are optional - var fields = new ExtractedFields + var candidates = new List { - Client = "Acme Corp", - Project = "Phase 2", - Hours = 3, - Rate = 150, - Currency = "USD", - Date = "2026-04-01" - // Description and PoNumber intentionally omitted + new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" }, + new() { Name = "Assured Guaranty EU Ltd", LegalEntity = "AG-EU-002" } }; + var handler = CreateOkHandler(JsonSerializer.Serialize(candidates)); + var plugin = CreatePluginWithMocks(counterpartyHandler: handler); - var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields)); + var resultJson = await plugin.LookupCounterparty("Assured Guaranty"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.NotNull(result.Candidates); + Assert.Equal(2, result.Candidates.Count); + } + + [Fact] + public async Task LookupCounterparty_ApiError_ReturnsErrorMessage() + { + var plugin = CreatePluginWithMocks(counterpartyHandler: CreateErrorHandler()); + + var resultJson = await plugin.LookupCounterparty("Test"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Counterparty API unavailable")); + } + + // --- ValidateTrade tests --- + + [Fact] + public async Task ValidateTrade_ValidId_ReturnsValid() + { + var handler = CreateOkHandler("{\"isValid\":true}"); + var plugin = CreatePluginWithMocks(tradeHandler: handler); + + var resultJson = await plugin.ValidateTrade(79353083); var result = JsonSerializer.Deserialize(resultJson); Assert.NotNull(result); Assert.True(result.IsValid); } + + [Fact] + public async Task ValidateTrade_InvalidId_ReturnsError() + { + var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Trade not found\"}"); + var plugin = CreatePluginWithMocks(tradeHandler: handler); + + var resultJson = await plugin.ValidateTrade(99999); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Trade not found")); + } + + [Fact] + public async Task ValidateTrade_ApiError_ReturnsErrorMessage() + { + var plugin = CreatePluginWithMocks(tradeHandler: CreateErrorHandler()); + + var resultJson = await plugin.ValidateTrade(12345); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Trade validation API unavailable")); + } + + // --- ValidateCurrency tests --- + + [Fact] + public async Task ValidateCurrency_ValidCode_ReturnsValid() + { + var handler = CreateOkHandler("{\"isValid\":true}"); + var plugin = CreatePluginWithMocks(currencyHandler: handler); + + var resultJson = await plugin.ValidateCurrency("GBP"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateCurrency_InvalidCode_ReturnsError() + { + var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Unknown currency\",\"suggestions\":[\"GBP\",\"GEL\"]}"); + var plugin = CreatePluginWithMocks(currencyHandler: handler); + + var resultJson = await plugin.ValidateCurrency("GBX"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("GBP")); + } + + [Fact] + public async Task ValidateCurrency_ApiError_ReturnsErrorMessage() + { + var plugin = CreatePluginWithMocks(currencyHandler: CreateErrorHandler()); + + var resultJson = await plugin.ValidateCurrency("USD"); + var result = JsonSerializer.Deserialize(resultJson); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Currency validation API unavailable")); + } } diff --git a/tests/ChatAgent.Api.Tests/FewShotServiceTests.cs b/tests/ChatAgent.Api.Tests/FewShotServiceTests.cs new file mode 100644 index 0000000..ab51285 --- /dev/null +++ b/tests/ChatAgent.Api.Tests/FewShotServiceTests.cs @@ -0,0 +1,75 @@ +// FewShotServiceTests.cs -- Tests for the FewShotService that loads few-shot +// examples from disk and assembles ChatHistory prefixes. + +using ChatAgent.Api.Services; +using ChatAgent.Shared.Models; + +namespace ChatAgent.Api.Tests; + +public class FewShotServiceTests +{ + // Path to the examples folder from the test bin directory. + // WebApplicationFactory resolves content root to the API project, + // but for direct unit tests we resolve from the test assembly location. + private static string GetExamplesPath() + { + // Navigate from test bin/Debug/net9.0/ up to the repo root, then into examples/ + var testDir = AppContext.BaseDirectory; + var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); + return Path.Combine(repoRoot, "examples", "extraction"); + } + + [Fact] + public void Constructor_LoadsInstructionTemplateAndExamples() + { + var service = new FewShotService(GetExamplesPath()); + + // Should have: 1 system message + 3 examples × 2 messages each = 7 messages + Assert.Equal(7, service.PrefixMessageCount); + } + + [Fact] + public void CloneWithEmail_AppendsEmailAsUserMessage() + { + var service = new FewShotService(GetExamplesPath()); + + var history = service.CloneWithEmail("Test email"); + + // Prefix (7) + 1 email user message = 8 + Assert.Equal(8, history.Count); + // Last message should be the email + Assert.Equal("Test email", history.Last().Content); + } + + [Fact] + public void CloneWithEmailAndMessages_AppendsEmailAndFollowUps() + { + var service = new FewShotService(GetExamplesPath()); + + var followUp = new List + { + new() { Role = "assistant", Content = "Which counterparty?" }, + new() { Role = "user", Content = "Option 1" } + }; + + var history = service.CloneWithEmailAndMessages("email", followUp); + + // Prefix (7) + 1 email + 2 follow-up = 10 + Assert.Equal(10, history.Count); + } + + [Fact] + public void CloneWithEmail_DoesNotMutatePrefix() + { + var service = new FewShotService(GetExamplesPath()); + + // Clone twice — second clone should not include the first email + var history1 = service.CloneWithEmail("email 1"); + var history2 = service.CloneWithEmail("email 2"); + + Assert.Equal(8, history1.Count); + Assert.Equal(8, history2.Count); + Assert.Equal("email 1", history1.Last().Content); + Assert.Equal("email 2", history2.Last().Content); + } +}