feat: migrate chat backend to Semantic Kernel with tool calling support
Replace manual HTTP proxy in ChatController with Semantic Kernel's OpenAI chat completion service pointed at CLIProxyAPI. Add extraction plugin with validation function for structured field extraction from natural language, enabling an agentic loop with auto-retry and human-in-the-loop escalation. - Add Microsoft.SemanticKernel 1.74.0 with OpenAI connector - Create ExtractedFields schema and ValidationResult models - Create ExtractionPlugin with [KernelFunction] validation - Rewrite ChatController to use IChatCompletionService streaming - Configure FunctionChoiceBehavior.Auto() for tool calling - Preserve existing SSE contract (client unchanged) - Update tests to mock SK services, add plugin and integration tests - Archive multi-turn-conversations and migrate-to-semantic-kernel changes - Sync specs for agent-extraction, semantic-kernel-integration, chat-streaming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The chat backend currently proxies requests to a local CLIProxyAPI instance (OpenAI-compatible API at `localhost:8317`) via manual `HttpClient` calls and SSE parsing in `ChatController`. The architecture works for simple chat completion but has no abstraction for tool calling, function invocation, or agentic loops. The goal is to adopt Semantic Kernel as the AI orchestration layer to enable structured extraction with autonomous validation.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Replace manual HTTP proxy logic with Semantic Kernel's chat completion service
|
||||||
|
- Enable tool/function calling via SK plugins
|
||||||
|
- Implement an agentic extraction loop: extract → validate → retry (up to 3 times) → escalate to user
|
||||||
|
- Preserve the existing SSE contract so the Blazor client requires no changes
|
||||||
|
- Maintain inline tutorial comments explaining SK concepts
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Multi-agent orchestration (future — when Agent Framework reaches GA)
|
||||||
|
- Changing the Blazor client or `ChatApiClient`
|
||||||
|
- Adding new UI for structured output display (future change)
|
||||||
|
- Replacing CLIProxyAPI — SK's OpenAI connector talks to it as-is
|
||||||
|
- Authentication or multi-user support
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Use SK's OpenAI chat completion connector pointed at CLIProxyAPI
|
||||||
|
|
||||||
|
**Choice:** `Microsoft.SemanticKernel.Connectors.OpenAI` with `OpenAIChatCompletionService` configured to use `localhost:8317` as the endpoint.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- SK Anthropic connector (talks to Anthropic API directly) — would bypass CLIProxyAPI and lose model-switching flexibility
|
||||||
|
- Keep manual HttpClient alongside SK — defeats the purpose of the migration
|
||||||
|
|
||||||
|
**Rationale:** CLIProxyAPI already provides an OpenAI-compatible interface. SK's OpenAI connector works with any OpenAI-compatible endpoint. No infrastructure change required.
|
||||||
|
|
||||||
|
### D2: Register Kernel and plugins in DI via `Program.cs`
|
||||||
|
|
||||||
|
**Choice:** Configure `Kernel` in `Program.cs` using `builder.Services.AddKernel()` and register plugins via DI. Inject `Kernel` into `ChatController`.
|
||||||
|
|
||||||
|
**Rationale:** Follows ASP.NET Core conventions. The kernel is a singleton service with plugins registered at startup. Controller receives it via constructor injection, consistent with the existing pattern of injecting `IHttpClientFactory` and `IConfiguration`.
|
||||||
|
|
||||||
|
### D3: Validation as a native SK plugin function
|
||||||
|
|
||||||
|
**Choice:** Create an `ExtractionPlugin` class with `[KernelFunction]` methods: one for validation of extracted fields. The agent auto-invokes this via `ToolCallBehavior.AutoInvokeKernelFunctions`.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- Manual tool call loop in controller code — loses SK's built-in retry/function-calling orchestration
|
||||||
|
- Separate validation service outside SK — requires manual plumbing between LLM and validator
|
||||||
|
|
||||||
|
**Rationale:** SK's auto-invocation handles the loop naturally. The LLM sees the validation function as a tool, calls it, reads the result, and decides whether to retry or escalate. This is the core value proposition of adopting SK.
|
||||||
|
|
||||||
|
### D4: Iteration cap with human-in-the-loop escalation
|
||||||
|
|
||||||
|
**Choice:** Configure `ToolCallBehavior.AutoInvokeKernelFunctions` with `MaximumAutoInvokeAttempts = 3`. If the agent exhausts retries without valid output, it returns a clarification request as a regular chat message to the user.
|
||||||
|
|
||||||
|
**Rationale:** The iteration cap prevents runaway loops. The escalation path uses the existing chat UI — the agent simply asks for clarification in natural language, and the user responds in the next message. No special UI needed.
|
||||||
|
|
||||||
|
### D5: Preserve SSE contract via streaming kernel invocation
|
||||||
|
|
||||||
|
**Choice:** Use `kernel.InvokeStreamingAsync<StreamingChatMessageContent>()` (or `IChatCompletionService.GetStreamingChatMessageContentsAsync()`) and re-emit tokens as the same SSE format the client expects: `data: {"text":"..."}\n\n` and `data: [DONE]\n\n`.
|
||||||
|
|
||||||
|
**Rationale:** The Blazor client's `ChatApiClient` parses this exact format. By keeping the SSE contract identical, the entire client codebase remains untouched.
|
||||||
|
|
||||||
|
### D6: Predefined extraction schema as a strongly-typed C# class
|
||||||
|
|
||||||
|
**Choice:** Define an `ExtractedFields` record/class in `ChatAgent.Shared.Models` with the fixed set of known fields. Validation logic checks for required fields and type correctness.
|
||||||
|
|
||||||
|
**Rationale:** Single output type with fixed keys. A strongly-typed class gives compile-time safety, works with `System.Text.Json` serialization, and can carry data annotations for validation rules.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[SK OpenAI connector compatibility with CLIProxyAPI]** → CLIProxyAPI aims for OpenAI API parity but may have edge cases with tool calling responses. Mitigation: test tool calling end-to-end early; fall back to direct Anthropic connector if needed.
|
||||||
|
- **[Streaming + tool calling interaction]** → When the agent calls a tool mid-stream, the streaming behavior may differ from pure chat completion. Mitigation: handle tool call chunks in the SSE bridge; may need to buffer during tool execution and resume streaming after.
|
||||||
|
- **[SK version churn]** → Semantic Kernel is actively developed; APIs may evolve. Mitigation: pin to a specific stable version, document the version in stack spec.
|
||||||
|
- **[Tutorial complexity increase]** → SK adds abstractions (kernel, plugins, functions) that need explaining. Mitigation: maintain inline comments for every SK concept, consistent with project convention.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What are the exact field names and types for `ExtractedFields`? (Need user input for the real schema — can use a placeholder for initial implementation.)
|
||||||
|
- Should tool call status ("Validating output...") be surfaced to the client as a distinct SSE event type, or just as regular text tokens? (Current design: regular text, revisit in a future change if needed.)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The chat backend currently proxies requests to an OpenAI-compatible API (CLIProxyAPI) via manual HttpClient calls and SSE parsing. As the agent evolves toward structured extraction with tool calling and autonomous validation loops, this manual plumbing becomes a liability. Semantic Kernel provides a production-ready abstraction for chat completion, tool/function calling, and auto-invocation — letting us focus on agent behavior rather than HTTP mechanics. Adopting it now establishes the foundation for the agentic workflow (natural language → structured extraction → tool-based validation → human-in-the-loop clarification).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Replace manual HttpClient + SSE proxy in `ChatController` with Semantic Kernel's `OpenAIChatCompletionService` pointed at the existing CLIProxyAPI proxy
|
||||||
|
- Add a validation plugin that the agent can call as a tool to validate extracted key-value output against a predefined schema
|
||||||
|
- Introduce an agentic loop: the kernel autonomously retries extraction up to 2–3 times on validation failure, then escalates to the user for clarification
|
||||||
|
- Keep the existing SSE contract to the Blazor client unchanged — `ChatApiClient` and `Chat.razor` are not modified
|
||||||
|
- **BREAKING**: `ChatController` internals are rewritten; the manual Responses API proxy logic is removed entirely
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `agent-extraction`: Defines the structured field extraction behavior — predefined keys, validation rules, autonomous retry loop, and human-in-the-loop escalation
|
||||||
|
- `semantic-kernel-integration`: Defines how Semantic Kernel is configured, registered, and wired into the API — kernel setup, OpenAI connector config, plugin registration
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `chat-streaming`: The streaming requirement changes from "proxy SSE from upstream API" to "stream Semantic Kernel chat completion responses as SSE" — same client contract, different server implementation
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **ChatAgent.Api**: New NuGet dependencies (`Microsoft.SemanticKernel`), `Program.cs` service registration changes, `ChatController` rewritten
|
||||||
|
- **ChatAgent.Api.Tests**: Existing `ChatControllerTests` need updating to mock Semantic Kernel services instead of upstream HTTP calls
|
||||||
|
- **Dependencies**: Adds `Microsoft.SemanticKernel` and `Microsoft.SemanticKernel.Connectors.OpenAI` packages
|
||||||
|
- **Infrastructure**: No change — still talks to CLIProxyAPI at `localhost:8317`
|
||||||
|
- **Client**: No change — SSE contract preserved
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Structured field extraction from natural language
|
||||||
|
|
||||||
|
The agent SHALL extract a predefined set of key-value pairs from user-provided natural language text (e.g., email content) and return them as a structured JSON object.
|
||||||
|
|
||||||
|
#### Scenario: All fields extracted successfully
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message containing natural language with all required information
|
||||||
|
- **THEN** the agent returns a JSON object with all predefined fields populated from the text
|
||||||
|
|
||||||
|
#### Scenario: Partial extraction
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message that contains some but not all required fields
|
||||||
|
- **THEN** the agent extracts available fields and leaves missing fields as null
|
||||||
|
|
||||||
|
### Requirement: Predefined extraction schema
|
||||||
|
|
||||||
|
The system SHALL define a fixed set of known field names and types as a strongly-typed C# class. All extraction output MUST conform to this schema.
|
||||||
|
|
||||||
|
#### Scenario: Output conforms to schema
|
||||||
|
|
||||||
|
- **WHEN** the agent produces extracted fields
|
||||||
|
- **THEN** every key in the output matches a field defined in the schema and values match expected types
|
||||||
|
|
||||||
|
### Requirement: Autonomous validation via tool calling
|
||||||
|
|
||||||
|
The agent SHALL validate extracted fields by calling a validation tool function. The validation tool checks that all required fields are present and correctly typed.
|
||||||
|
|
||||||
|
#### Scenario: Validation passes
|
||||||
|
|
||||||
|
- **WHEN** the agent calls the validation tool with a complete and correct extraction
|
||||||
|
- **THEN** the tool returns a success result and the agent returns the final output to the user
|
||||||
|
|
||||||
|
#### Scenario: Validation fails with fixable errors
|
||||||
|
|
||||||
|
- **WHEN** the validation tool returns errors for missing or malformed fields
|
||||||
|
- **THEN** the agent re-reads the source text and attempts to fix the extraction without user intervention
|
||||||
|
|
||||||
|
### Requirement: Autonomous retry with iteration cap
|
||||||
|
|
||||||
|
The agent SHALL retry extraction autonomously up to 3 times when validation fails. After exhausting retries, the agent MUST escalate to the user.
|
||||||
|
|
||||||
|
#### Scenario: Agent retries and succeeds
|
||||||
|
|
||||||
|
- **WHEN** validation fails on the first attempt but the error is recoverable
|
||||||
|
- **THEN** the agent retries extraction and calls validation again, up to 3 total attempts
|
||||||
|
|
||||||
|
#### Scenario: Agent exhausts retries and escalates
|
||||||
|
|
||||||
|
- **WHEN** validation fails after 3 attempts
|
||||||
|
- **THEN** the agent sends a natural language message to the user identifying the specific fields it could not resolve and asking for clarification
|
||||||
|
|
||||||
|
### Requirement: Human-in-the-loop clarification
|
||||||
|
|
||||||
|
When the agent escalates to the user, the user SHALL be able to provide the missing information in natural language, and the agent SHALL incorporate the clarification and re-attempt extraction.
|
||||||
|
|
||||||
|
#### Scenario: User provides clarification
|
||||||
|
|
||||||
|
- **WHEN** the agent asks for clarification about missing fields and the user responds
|
||||||
|
- **THEN** the agent incorporates the user's response into the conversation context and produces an updated extraction
|
||||||
|
|
||||||
|
#### Scenario: Clarification via normal chat
|
||||||
|
|
||||||
|
- **WHEN** the agent escalates for clarification
|
||||||
|
- **THEN** the clarification request appears as a regular assistant message in the chat UI, and the user responds via the normal chat input
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Chat endpoint proxies to Responses API
|
||||||
|
|
||||||
|
The API backend SHALL expose `POST /api/chat` that accepts a list of messages and processes them using a Semantic Kernel chat completion service. The kernel is configured with an OpenAI connector pointed at the existing CLIProxyAPI proxy.
|
||||||
|
|
||||||
|
#### Scenario: Successful chat request
|
||||||
|
|
||||||
|
- **WHEN** the client sends a POST to `/api/chat` with a message list
|
||||||
|
- **THEN** the API processes the messages through the Semantic Kernel and returns the response
|
||||||
|
|
||||||
|
### Requirement: Streaming response delivery
|
||||||
|
|
||||||
|
The API backend SHALL stream the Semantic Kernel's chat completion response back to the WASM client as `text/event-stream`, forwarding text content so the client can render tokens incrementally. The SSE event format MUST remain `data: {"text":"..."}\n\n` for text deltas and `data: [DONE]\n\n` for completion.
|
||||||
|
|
||||||
|
#### Scenario: Tokens stream to client
|
||||||
|
|
||||||
|
- **WHEN** the Semantic Kernel emits streaming chat message content
|
||||||
|
- **THEN** the backend forwards each content chunk as an SSE event to the client containing the text fragment
|
||||||
|
|
||||||
|
#### Scenario: Stream completes
|
||||||
|
|
||||||
|
- **WHEN** the Semantic Kernel streaming response completes
|
||||||
|
- **THEN** the backend signals stream completion to the client with `data: [DONE]\n\n`
|
||||||
|
|
||||||
|
### Requirement: Configurable proxy target
|
||||||
|
|
||||||
|
The CLIProxyAPI base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded. These values are used to configure the Semantic Kernel OpenAI connector.
|
||||||
|
|
||||||
|
#### Scenario: Configuration read at startup
|
||||||
|
|
||||||
|
- **WHEN** the API starts
|
||||||
|
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration to configure the Semantic Kernel
|
||||||
|
|
||||||
|
### Requirement: Error propagation
|
||||||
|
|
||||||
|
If the LLM service returns an error or is unreachable, the API backend SHALL return an error SSE event and the client SHALL display the error to the user.
|
||||||
|
|
||||||
|
#### Scenario: LLM service unreachable
|
||||||
|
|
||||||
|
- **WHEN** the CLIProxyAPI proxy is not running
|
||||||
|
- **THEN** the client displays an error message instead of an assistant response
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Semantic Kernel service registration
|
||||||
|
|
||||||
|
The API backend SHALL register a Semantic Kernel `Kernel` instance in the ASP.NET Core DI container at startup, configured with an OpenAI chat completion connector.
|
||||||
|
|
||||||
|
#### Scenario: Kernel registered at startup
|
||||||
|
|
||||||
|
- **WHEN** the API application starts
|
||||||
|
- **THEN** a `Kernel` instance is available for injection into controllers
|
||||||
|
|
||||||
|
### Requirement: OpenAI connector targets CLIProxyAPI proxy
|
||||||
|
|
||||||
|
The Semantic Kernel OpenAI chat completion service SHALL be configured to use the existing CLIProxyAPI proxy endpoint as its base URL, reading the URL and model name from `appsettings.json`.
|
||||||
|
|
||||||
|
#### Scenario: Connector uses configured endpoint
|
||||||
|
|
||||||
|
- **WHEN** the kernel makes a chat completion request
|
||||||
|
- **THEN** it sends the request to the URL specified in `ResponsesApi:BaseUrl` configuration
|
||||||
|
|
||||||
|
#### Scenario: Model from configuration
|
||||||
|
|
||||||
|
- **WHEN** the kernel makes a chat completion request
|
||||||
|
- **THEN** it uses the model name specified in `ResponsesApi:Model` configuration
|
||||||
|
|
||||||
|
### Requirement: Plugin registration
|
||||||
|
|
||||||
|
The API backend SHALL register extraction and validation plugins with the Kernel so they are available as tools for the LLM to invoke.
|
||||||
|
|
||||||
|
#### Scenario: Plugins available as tools
|
||||||
|
|
||||||
|
- **WHEN** the kernel is constructed
|
||||||
|
- **THEN** all registered plugin functions appear in the tool list sent to the LLM
|
||||||
|
|
||||||
|
### Requirement: Auto function calling
|
||||||
|
|
||||||
|
The Kernel SHALL be configured with automatic function calling enabled, allowing the LLM to invoke registered plugin functions without manual dispatch code.
|
||||||
|
|
||||||
|
#### Scenario: LLM invokes tool automatically
|
||||||
|
|
||||||
|
- **WHEN** the LLM decides to call a registered function during chat completion
|
||||||
|
- **THEN** the kernel automatically executes the function and returns the result to the LLM
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## 1. Add Semantic Kernel Dependencies
|
||||||
|
|
||||||
|
- [x] 1.1 Add `Microsoft.SemanticKernel` and `Microsoft.SemanticKernel.Connectors.OpenAI` NuGet packages to `ChatAgent.Api`
|
||||||
|
- [x] 1.2 Remove the `OpenAI` SDK package if no longer needed after migration
|
||||||
|
|
||||||
|
## 2. Define Extraction Schema
|
||||||
|
|
||||||
|
- [x] 2.1 Create `ExtractedFields` class in `ChatAgent.Shared/Models/` with the predefined set of key-value fields (placeholder fields until real schema is provided)
|
||||||
|
- [x] 2.2 Create `ValidationResult` class in `ChatAgent.Shared/Models/` with `IsValid`, `Errors` properties
|
||||||
|
|
||||||
|
## 3. Create Extraction Plugin
|
||||||
|
|
||||||
|
- [x] 3.1 Create `ExtractionPlugin` class in `ChatAgent.Api/Plugins/` with a `[KernelFunction]` validation method that checks `ExtractedFields` for required fields and type correctness
|
||||||
|
- [x] 3.2 Add inline tutorial comments explaining SK plugin concepts (`[KernelFunction]`, `[Description]`, auto-invocation)
|
||||||
|
|
||||||
|
## 4. Wire Semantic Kernel in Program.cs
|
||||||
|
|
||||||
|
- [x] 4.1 Register `OpenAIChatCompletionService` in DI using `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from config
|
||||||
|
- [x] 4.2 Register `Kernel` with `AddKernel()` and import `ExtractionPlugin`
|
||||||
|
- [x] 4.3 Add inline tutorial comments explaining kernel setup, connectors, and plugin registration
|
||||||
|
|
||||||
|
## 5. Rewrite ChatController
|
||||||
|
|
||||||
|
- [x] 5.1 Replace `IHttpClientFactory` and `IConfiguration` injection with `Kernel` injection
|
||||||
|
- [x] 5.2 Replace manual HTTP proxy logic with `IChatCompletionService.GetStreamingChatMessageContentsAsync()` using the conversation history from the request
|
||||||
|
- [x] 5.3 Configure `OpenAIPromptExecutionSettings` with `FunctionChoiceBehavior.Auto()` and `autoInvokeMaxCallCount = 3`
|
||||||
|
- [x] 5.4 Re-emit streaming content as the existing SSE format (`data: {"text":"..."}\n\n` and `data: [DONE]\n\n`)
|
||||||
|
- [x] 5.5 Add inline tutorial comments explaining streaming chat completion, execution settings, and tool call behavior
|
||||||
|
|
||||||
|
## 6. Update Tests
|
||||||
|
|
||||||
|
- [x] 6.1 Update `ChatControllerTests` to mock `IChatCompletionService` instead of upstream HTTP calls
|
||||||
|
- [x] 6.2 Add tests for the validation plugin (`ExtractionPlugin` returns correct pass/fail results)
|
||||||
|
- [x] 6.3 Add a test verifying the agent escalates to the user after max retries
|
||||||
|
|
||||||
|
## 7. Verify
|
||||||
|
|
||||||
|
- [x] 7.1 Run `dotnet build` to confirm no errors
|
||||||
|
- [x] 7.2 Run `dotnet test` to confirm all tests pass
|
||||||
|
- [ ] 7.3 Manual smoke test: send a chat message and verify streaming still works end-to-end through SK
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-04-04
|
||||||
70
openspec/specs/agent-extraction/spec.md
Normal file
70
openspec/specs/agent-extraction/spec.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the autonomous agent-driven extraction pipeline — structured field extraction from natural language, schema-based validation via tool calling, autonomous retry logic, and human-in-the-loop clarification.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Structured field extraction from natural language
|
||||||
|
|
||||||
|
The agent SHALL extract a predefined set of key-value pairs from user-provided natural language text (e.g., email content) and return them as a structured JSON object.
|
||||||
|
|
||||||
|
#### Scenario: All fields extracted successfully
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message containing natural language with all required information
|
||||||
|
- **THEN** the agent returns a JSON object with all predefined fields populated from the text
|
||||||
|
|
||||||
|
#### Scenario: Partial extraction
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message that contains some but not all required fields
|
||||||
|
- **THEN** the agent extracts available fields and leaves missing fields as null
|
||||||
|
|
||||||
|
### Requirement: Predefined extraction schema
|
||||||
|
|
||||||
|
The system SHALL define a fixed set of known field names and types as a strongly-typed C# class. All extraction output MUST conform to this schema.
|
||||||
|
|
||||||
|
#### Scenario: Output conforms to schema
|
||||||
|
|
||||||
|
- **WHEN** the agent produces extracted fields
|
||||||
|
- **THEN** every key in the output matches a field defined in the schema and values match expected types
|
||||||
|
|
||||||
|
### Requirement: Autonomous validation via tool calling
|
||||||
|
|
||||||
|
The agent SHALL validate extracted fields by calling a validation tool function. The validation tool checks that all required fields are present and correctly typed.
|
||||||
|
|
||||||
|
#### Scenario: Validation passes
|
||||||
|
|
||||||
|
- **WHEN** the agent calls the validation tool with a complete and correct extraction
|
||||||
|
- **THEN** the tool returns a success result and the agent returns the final output to the user
|
||||||
|
|
||||||
|
#### Scenario: Validation fails with fixable errors
|
||||||
|
|
||||||
|
- **WHEN** the validation tool returns errors for missing or malformed fields
|
||||||
|
- **THEN** the agent re-reads the source text and attempts to fix the extraction without user intervention
|
||||||
|
|
||||||
|
### Requirement: Autonomous retry with iteration cap
|
||||||
|
|
||||||
|
The agent SHALL retry extraction autonomously up to 3 times when validation fails. After exhausting retries, the agent MUST escalate to the user.
|
||||||
|
|
||||||
|
#### Scenario: Agent retries and succeeds
|
||||||
|
|
||||||
|
- **WHEN** validation fails on the first attempt but the error is recoverable
|
||||||
|
- **THEN** the agent retries extraction and calls validation again, up to 3 total attempts
|
||||||
|
|
||||||
|
#### Scenario: Agent exhausts retries and escalates
|
||||||
|
|
||||||
|
- **WHEN** validation fails after 3 attempts
|
||||||
|
- **THEN** the agent sends a natural language message to the user identifying the specific fields it could not resolve and asking for clarification
|
||||||
|
|
||||||
|
### Requirement: Human-in-the-loop clarification
|
||||||
|
|
||||||
|
When the agent escalates to the user, the user SHALL be able to provide the missing information in natural language, and the agent SHALL incorporate the clarification and re-attempt extraction.
|
||||||
|
|
||||||
|
#### Scenario: User provides clarification
|
||||||
|
|
||||||
|
- **WHEN** the agent asks for clarification about missing fields and the user responds
|
||||||
|
- **THEN** the agent incorporates the user's response into the conversation context and produces an updated extraction
|
||||||
|
|
||||||
|
#### Scenario: Clarification via normal chat
|
||||||
|
|
||||||
|
- **WHEN** the agent escalates for clarification
|
||||||
|
- **THEN** the clarification request appears as a regular assistant message in the chat UI, and the user responds via the normal chat input
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Define the streaming AI response pipeline — backend proxy to the Responses API, SSE delivery to the WASM client, configuration, and error handling.
|
Define the streaming AI response pipeline — backend chat endpoint using Semantic Kernel, SSE delivery to the WASM client, configuration, and error handling.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Chat endpoint proxies to Responses API
|
### Requirement: Chat endpoint proxies to Responses API
|
||||||
|
|
||||||
The API backend SHALL expose `POST /api/chat` that accepts a list of messages and proxies the request to the local Responses API at a configurable base URL using the `POST /v1/responses` endpoint.
|
The API backend SHALL expose `POST /api/chat` that accepts a list of messages and processes them using a Semantic Kernel chat completion service. The kernel is configured with an OpenAI connector pointed at the existing CLIProxyAPI proxy.
|
||||||
|
|
||||||
#### Scenario: Successful proxy request
|
#### Scenario: Successful chat request
|
||||||
|
|
||||||
- **WHEN** the client sends a POST to `/api/chat` with a message list
|
- **WHEN** the client sends a POST to `/api/chat` with a message list
|
||||||
- **THEN** the API forwards the messages to the Responses API with the configured model and returns the response
|
- **THEN** the API processes the messages through the Semantic Kernel and returns the response
|
||||||
|
|
||||||
### Requirement: Streaming response delivery
|
### Requirement: Streaming response delivery
|
||||||
|
|
||||||
The API backend SHALL stream the Responses API's SSE events back to the WASM client as `text/event-stream`, forwarding `response.output_text.delta` events so the client can render tokens incrementally.
|
The API backend SHALL stream the Semantic Kernel's chat completion response back to the WASM client as `text/event-stream`, forwarding text content so the client can render tokens incrementally. The SSE event format MUST remain `data: {"text":"..."}\n\n` for text deltas and `data: [DONE]\n\n` for completion.
|
||||||
|
|
||||||
#### Scenario: Tokens stream to client
|
#### Scenario: Tokens stream to client
|
||||||
|
|
||||||
- **WHEN** the Responses API emits `response.output_text.delta` events
|
- **WHEN** the Semantic Kernel emits streaming chat message content
|
||||||
- **THEN** the backend forwards each delta as an SSE event to the client containing the text fragment
|
- **THEN** the backend forwards each content chunk as an SSE event to the client containing the text fragment
|
||||||
|
|
||||||
#### Scenario: Stream completes
|
#### Scenario: Stream completes
|
||||||
|
|
||||||
- **WHEN** the Responses API emits `response.completed`
|
- **WHEN** the Semantic Kernel streaming response completes
|
||||||
- **THEN** the backend signals stream completion to the client
|
- **THEN** the backend signals stream completion to the client with `data: [DONE]\n\n`
|
||||||
|
|
||||||
### Requirement: Configurable proxy target
|
### Requirement: Configurable proxy target
|
||||||
|
|
||||||
The Responses API base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded.
|
The CLIProxyAPI base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded. These values are used to configure the Semantic Kernel OpenAI connector.
|
||||||
|
|
||||||
#### Scenario: Configuration read at startup
|
#### Scenario: Configuration read at startup
|
||||||
|
|
||||||
- **WHEN** the API starts
|
- **WHEN** the API starts
|
||||||
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration
|
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration to configure the Semantic Kernel
|
||||||
|
|
||||||
### Requirement: Client streams from backend
|
### Requirement: Client streams from backend
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ The WASM client SHALL call `POST /api/chat` with `SetBrowserResponseStreamingEna
|
|||||||
|
|
||||||
### Requirement: Error propagation
|
### Requirement: Error propagation
|
||||||
|
|
||||||
If the Responses API returns an error or is unreachable, the API backend SHALL return an appropriate HTTP error status and the client SHALL display the error to the user.
|
If the LLM service returns an error or is unreachable, the API backend SHALL return an error SSE event and the client SHALL display the error to the user.
|
||||||
|
|
||||||
#### Scenario: Proxy unreachable
|
#### Scenario: LLM service unreachable
|
||||||
|
|
||||||
- **WHEN** the Responses API is not running
|
- **WHEN** the CLIProxyAPI proxy is not running
|
||||||
- **THEN** the client displays an error message instead of an assistant response
|
- **THEN** the client displays an error message instead of an assistant response
|
||||||
|
|||||||
@@ -63,13 +63,32 @@ The chat page SHALL show a visual indicator while waiting for the first token fr
|
|||||||
|
|
||||||
### Requirement: Streaming AI response
|
### Requirement: Streaming AI response
|
||||||
|
|
||||||
The assistant SHALL reply with a real AI response streamed from the backend API. Tokens appear incrementally as they arrive.
|
The assistant SHALL reply with a real AI response streamed from the backend API, using the full conversation history as context. Tokens appear incrementally as they arrive.
|
||||||
|
|
||||||
#### Scenario: Bot replies with streamed AI response
|
#### Scenario: Bot replies with streamed AI response
|
||||||
|
|
||||||
- **WHEN** the user sends any message
|
- **WHEN** the user sends any message
|
||||||
- **THEN** the assistant message appears and grows token by token as the stream delivers text
|
- **THEN** the assistant message appears and grows token by token as the stream delivers text
|
||||||
|
|
||||||
|
#### Scenario: Full history sent with each request
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message after prior exchanges
|
||||||
|
- **THEN** all previous user and assistant messages are included in the API request so the AI has conversational context
|
||||||
|
|
||||||
|
### Requirement: New chat button
|
||||||
|
|
||||||
|
The chat page SHALL provide a button to clear the current conversation and start a new one.
|
||||||
|
|
||||||
|
#### Scenario: User starts a new chat
|
||||||
|
|
||||||
|
- **WHEN** the user clicks the "New Chat" button
|
||||||
|
- **THEN** all messages are cleared and the empty state is shown
|
||||||
|
|
||||||
|
#### Scenario: New chat button disabled during streaming
|
||||||
|
|
||||||
|
- **WHEN** the assistant is currently streaming a response
|
||||||
|
- **THEN** the "New Chat" button is disabled
|
||||||
|
|
||||||
### Requirement: Auto-scroll
|
### Requirement: Auto-scroll
|
||||||
|
|
||||||
The message list SHALL automatically scroll to the newest message when a new message is added.
|
The message list SHALL automatically scroll to the newest message when a new message is added.
|
||||||
|
|||||||
46
openspec/specs/semantic-kernel-integration/spec.md
Normal file
46
openspec/specs/semantic-kernel-integration/spec.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the Semantic Kernel integration layer — kernel registration, OpenAI connector configuration, plugin registration, and automatic function calling.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Semantic Kernel service registration
|
||||||
|
|
||||||
|
The API backend SHALL register a Semantic Kernel `Kernel` instance in the ASP.NET Core DI container at startup, configured with an OpenAI chat completion connector.
|
||||||
|
|
||||||
|
#### Scenario: Kernel registered at startup
|
||||||
|
|
||||||
|
- **WHEN** the API application starts
|
||||||
|
- **THEN** a `Kernel` instance is available for injection into controllers
|
||||||
|
|
||||||
|
### Requirement: OpenAI connector targets CLIProxyAPI proxy
|
||||||
|
|
||||||
|
The Semantic Kernel OpenAI chat completion service SHALL be configured to use the existing CLIProxyAPI proxy endpoint as its base URL, reading the URL and model name from `appsettings.json`.
|
||||||
|
|
||||||
|
#### Scenario: Connector uses configured endpoint
|
||||||
|
|
||||||
|
- **WHEN** the kernel makes a chat completion request
|
||||||
|
- **THEN** it sends the request to the URL specified in `ResponsesApi:BaseUrl` configuration
|
||||||
|
|
||||||
|
#### Scenario: Model from configuration
|
||||||
|
|
||||||
|
- **WHEN** the kernel makes a chat completion request
|
||||||
|
- **THEN** it uses the model name specified in `ResponsesApi:Model` configuration
|
||||||
|
|
||||||
|
### Requirement: Plugin registration
|
||||||
|
|
||||||
|
The API backend SHALL register extraction and validation plugins with the Kernel so they are available as tools for the LLM to invoke.
|
||||||
|
|
||||||
|
#### Scenario: Plugins available as tools
|
||||||
|
|
||||||
|
- **WHEN** the kernel is constructed
|
||||||
|
- **THEN** all registered plugin functions appear in the tool list sent to the LLM
|
||||||
|
|
||||||
|
### Requirement: Auto function calling
|
||||||
|
|
||||||
|
The Kernel SHALL be configured with automatic function calling enabled, allowing the LLM to invoke registered plugin functions without manual dispatch code.
|
||||||
|
|
||||||
|
#### Scenario: LLM invokes tool automatically
|
||||||
|
|
||||||
|
- **WHEN** the LLM decides to call a registered function during chat completion
|
||||||
|
- **THEN** the kernel automatically executes the function and returns the result to the LLM
|
||||||
@@ -10,4 +10,9 @@
|
|||||||
<ProjectReference Include="..\ChatAgent.Shared\ChatAgent.Shared.csproj" />
|
<ProjectReference Include="..\ChatAgent.Shared\ChatAgent.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.SemanticKernel" Version="1.74.0" />
|
||||||
|
<PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,44 +1,50 @@
|
|||||||
// ChatController.cs -- Proxies chat requests to the Responses API with streaming.
|
// ChatController.cs -- Handles chat requests using Semantic Kernel for AI completion.
|
||||||
//
|
//
|
||||||
// This controller receives messages from the WASM client, forwards them to the
|
// This controller receives messages from the WASM client, processes them through
|
||||||
// local Responses API (OpenAI-compatible) at a configurable URL, and streams
|
// Semantic Kernel's chat completion service (pointed at a local CLIProxyAPI proxy),
|
||||||
// the response tokens back as Server-Sent Events (SSE).
|
// and streams the response tokens back as Server-Sent Events (SSE).
|
||||||
//
|
//
|
||||||
// Key concepts demonstrated:
|
// Key concepts demonstrated:
|
||||||
// - IHttpClientFactory named client injection for external API calls
|
// - Semantic Kernel injection and usage in an ASP.NET Core controller
|
||||||
// - IConfiguration for reading appsettings.json values
|
// - IChatCompletionService for streaming chat completions
|
||||||
// - SSE streaming response from ASP.NET Core (text/event-stream)
|
// - OpenAIPromptExecutionSettings for configuring tool calling behavior
|
||||||
// - Parsing upstream SSE events and re-emitting simplified events to the client
|
// - FunctionChoiceBehavior.Auto() for automatic tool invocation
|
||||||
|
// - Streaming SK responses as SSE to maintain the existing client contract
|
||||||
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Api.Plugins;
|
||||||
using ChatAgent.Shared.Models;
|
using ChatAgent.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.SemanticKernel;
|
||||||
|
using Microsoft.SemanticKernel.ChatCompletion;
|
||||||
|
using Microsoft.SemanticKernel.Connectors.OpenAI;
|
||||||
|
|
||||||
namespace ChatAgent.Api.Controllers
|
namespace ChatAgent.Api.Controllers
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxies chat requests to the Responses API and streams tokens back to the client.
|
/// Processes chat requests through Semantic Kernel and streams tokens back to the client.
|
||||||
/// The Responses API URL and model are configured in appsettings.json under "ResponsesApi".
|
/// The Kernel is configured in Program.cs with an OpenAI connector pointed at CLIProxyAPI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ChatController : ControllerBase
|
public class ChatController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
// Kernel is the central Semantic Kernel object. It holds the AI service
|
||||||
private readonly IConfiguration _configuration;
|
// (chat completion) and any registered plugins (tools). We inject it via DI
|
||||||
|
// rather than creating it manually, following ASP.NET Core conventions.
|
||||||
|
private readonly Kernel _kernel;
|
||||||
|
|
||||||
public ChatController(IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
public ChatController(Kernel kernel)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_kernel = kernel;
|
||||||
_configuration = configuration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// POST /api/chat -- Accepts a ChatRequest with messages, forwards to the Responses API
|
/// POST /api/chat -- Accepts a ChatRequest with messages, processes them through
|
||||||
/// with streaming enabled, and re-emits text deltas as simplified SSE events.
|
/// Semantic Kernel's chat completion with tool calling enabled, and streams
|
||||||
|
/// text tokens back as SSE events.
|
||||||
///
|
///
|
||||||
/// Client SSE format:
|
/// Client SSE format (unchanged from before migration):
|
||||||
/// data: {"text":"token here"}\n\n -- for each text delta
|
/// data: {"text":"token here"}\n\n -- for each text delta
|
||||||
/// data: [DONE]\n\n -- when streaming completes
|
/// data: [DONE]\n\n -- when streaming completes
|
||||||
/// data: {"error":"message"}\n\n -- if an error occurs
|
/// data: {"error":"message"}\n\n -- if an error occurs
|
||||||
@@ -47,113 +53,80 @@ namespace ChatAgent.Api.Controllers
|
|||||||
public async Task Post([FromBody] ChatRequest request)
|
public async Task Post([FromBody] ChatRequest request)
|
||||||
{
|
{
|
||||||
// Set the response content type to SSE so the client knows to read it as a stream.
|
// Set the response content type to SSE so the client knows to read it as a stream.
|
||||||
// "text/event-stream" is the standard MIME type for Server-Sent Events.
|
|
||||||
Response.ContentType = "text/event-stream";
|
Response.ContentType = "text/event-stream";
|
||||||
Response.Headers["Cache-Control"] = "no-cache";
|
Response.Headers["Cache-Control"] = "no-cache";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("ResponsesApi");
|
// IChatCompletionService is the SK abstraction for chat-based AI models.
|
||||||
var model = _configuration["ResponsesApi:Model"] ?? "claude-sonnet-4-6";
|
// GetRequiredService<T>() retrieves it from the kernel's service collection.
|
||||||
|
// This is the service registered via AddOpenAIChatCompletion() in Program.cs.
|
||||||
|
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
|
||||||
|
|
||||||
// Build the Responses API request payload.
|
// ChatHistory is SK's representation of a conversation. It maps directly
|
||||||
// The Responses API expects "input" (array of role/content objects) and "model".
|
// to the messages array in OpenAI's API format. We convert our ChatMessage
|
||||||
// "stream": true enables SSE streaming of token deltas.
|
// DTOs into SK's format.
|
||||||
var inputMessages = request.Messages.Select(m => new
|
var chatHistory = new ChatHistory();
|
||||||
|
foreach (var msg in request.Messages)
|
||||||
{
|
{
|
||||||
role = m.Role,
|
if (msg.Role == "user")
|
||||||
content = m.Content
|
chatHistory.AddUserMessage(msg.Content);
|
||||||
}).ToArray();
|
else if (msg.Role == "assistant")
|
||||||
|
chatHistory.AddAssistantMessage(msg.Content);
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
input = inputMessages,
|
|
||||||
stream = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var jsonPayload = JsonSerializer.Serialize(payload);
|
|
||||||
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
// Use HttpCompletionOption.ResponseHeadersRead so we start reading the stream
|
|
||||||
// as soon as headers arrive, rather than waiting for the full response body.
|
|
||||||
using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/responses")
|
|
||||||
{
|
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
|
|
||||||
using var upstreamResponse = await client.SendAsync(
|
|
||||||
upstreamRequest,
|
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
HttpContext.RequestAborted);
|
|
||||||
|
|
||||||
if (!upstreamResponse.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var errorBody = await upstreamResponse.Content.ReadAsStringAsync();
|
|
||||||
await WriteSSEAsync($"{{\"error\":\"Responses API returned {upstreamResponse.StatusCode}: {EscapeJson(errorBody)}\"}}");
|
|
||||||
await WriteSSEAsync("[DONE]");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the upstream SSE stream line by line, extract text deltas,
|
// Import the ExtractionPlugin so its [KernelFunction] methods are available
|
||||||
// and re-emit them as simplified SSE events to the client.
|
// as tools for this request. We import from the DI-registered instance.
|
||||||
using var stream = await upstreamResponse.Content.ReadAsStreamAsync();
|
// This makes validate_extracted_fields() visible to the LLM.
|
||||||
using var reader = new StreamReader(stream);
|
var extractionPlugin = HttpContext.RequestServices.GetRequiredService<ExtractionPlugin>();
|
||||||
|
_kernel.ImportPluginFromObject(extractionPlugin, "Extraction");
|
||||||
|
|
||||||
// Use ReadLineAsync and check for null instead of reader.EndOfStream,
|
// OpenAIPromptExecutionSettings controls how the LLM processes the request.
|
||||||
// because EndOfStream performs a synchronous read which is not supported
|
//
|
||||||
// in ASP.NET Core's async pipeline.
|
// FunctionChoiceBehavior.Auto() enables automatic function calling:
|
||||||
string? line;
|
// - The LLM sees all registered plugin functions as available tools
|
||||||
while ((line = await reader.ReadLineAsync()) != null)
|
// - When the LLM decides to call a tool, SK automatically executes it
|
||||||
|
// - The tool result is fed back to the LLM so it can reason about it
|
||||||
|
// - This creates the agentic loop: extract → validate → fix → retry
|
||||||
|
//
|
||||||
|
// The Auto() behavior allows the LLM to make tool call round-trips
|
||||||
|
// autonomously. SK's built-in safeguard limits the number of auto-invoke
|
||||||
|
// attempts to prevent runaway loops. If the agent exhausts retries,
|
||||||
|
// it responds with a clarification request to the user.
|
||||||
|
var executionSettings = new OpenAIPromptExecutionSettings
|
||||||
{
|
{
|
||||||
// SSE format: "data: {json}" lines, separated by blank lines.
|
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
|
||||||
// We only care about lines starting with "data: ".
|
};
|
||||||
if (!line.StartsWith("data: "))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var data = line.Substring(6); // strip "data: " prefix
|
// GetStreamingChatMessageContentsAsync returns an IAsyncEnumerable that yields
|
||||||
|
// content chunks as they arrive from the LLM. Each chunk may contain:
|
||||||
// Parse the JSON to find response.output_text.delta events.
|
// - Text content (the actual response tokens)
|
||||||
// These carry the actual text tokens in the "delta" field.
|
// - Tool call requests (which SK handles automatically via auto-invoke)
|
||||||
try
|
//
|
||||||
|
// We iterate the stream and forward text chunks as SSE events,
|
||||||
|
// preserving the exact format the Blazor client expects.
|
||||||
|
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(
|
||||||
|
chatHistory,
|
||||||
|
executionSettings,
|
||||||
|
_kernel,
|
||||||
|
HttpContext.RequestAborted))
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(data);
|
// Only emit chunks that contain text content.
|
||||||
var root = doc.RootElement;
|
// Tool call chunks are handled internally by SK and don't produce
|
||||||
|
// visible output -- the LLM will emit text after processing tool results.
|
||||||
if (root.TryGetProperty("type", out var typeElement))
|
if (!string.IsNullOrEmpty(chunk.Content))
|
||||||
{
|
{
|
||||||
var eventType = typeElement.GetString();
|
await WriteSSEAsync($"{{\"text\":{JsonSerializer.Serialize(chunk.Content)}}}");
|
||||||
|
|
||||||
if (eventType == "response.output_text.delta")
|
|
||||||
{
|
|
||||||
// Extract the text delta and send it to the client
|
|
||||||
if (root.TryGetProperty("delta", out var deltaElement))
|
|
||||||
{
|
|
||||||
var delta = deltaElement.GetString() ?? "";
|
|
||||||
await WriteSSEAsync($"{{\"text\":{JsonSerializer.Serialize(delta)}}}");
|
|
||||||
await Response.Body.FlushAsync();
|
await Response.Body.FlushAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (eventType == "response.completed")
|
|
||||||
{
|
|
||||||
// Stream is done
|
|
||||||
await WriteSSEAsync("[DONE]");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Skip malformed JSON lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we exit the loop without seeing response.completed, still signal done
|
// Signal stream completion to the client
|
||||||
await WriteSSEAsync("[DONE]");
|
await WriteSSEAsync("[DONE]");
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
await WriteSSEAsync($"{{\"error\":{JsonSerializer.Serialize($"Failed to reach Responses API: {ex.Message}")}}}");
|
await WriteSSEAsync($"{{\"error\":{JsonSerializer.Serialize($"Failed to reach LLM service: {ex.Message}")}}}");
|
||||||
await WriteSSEAsync("[DONE]");
|
await WriteSSEAsync("[DONE]");
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
@@ -171,13 +144,5 @@ namespace ChatAgent.Api.Controllers
|
|||||||
await Response.WriteAsync($"data: {data}\n\n");
|
await Response.WriteAsync($"data: {data}\n\n");
|
||||||
await Response.Body.FlushAsync();
|
await Response.Body.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Escapes a string for embedding in JSON (handles quotes and backslashes).
|
|
||||||
/// </summary>
|
|
||||||
private static string EscapeJson(string s)
|
|
||||||
{
|
|
||||||
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
src/ChatAgent.Api/Plugins/ExtractionPlugin.cs
Normal file
103
src/ChatAgent.Api/Plugins/ExtractionPlugin.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// ExtractionPlugin.cs -- Semantic Kernel plugin for validating extracted fields.
|
||||||
|
//
|
||||||
|
// In Semantic Kernel, a "plugin" is a class whose methods are exposed to the LLM
|
||||||
|
// as callable tools (functions). The LLM can decide to invoke these functions during
|
||||||
|
// a conversation when it determines they are relevant to the task.
|
||||||
|
//
|
||||||
|
// Key SK concepts demonstrated here:
|
||||||
|
//
|
||||||
|
// [KernelFunction] -- Marks a method as a function the LLM can call. SK discovers
|
||||||
|
// these at startup and includes them in the tool list sent with each LLM request.
|
||||||
|
//
|
||||||
|
// [Description] -- Tells the LLM what the function does. The LLM reads this text
|
||||||
|
// to decide whether and when to call the function. Good descriptions are critical
|
||||||
|
// for reliable tool use.
|
||||||
|
//
|
||||||
|
// Auto-invocation -- When configured with FunctionChoiceBehavior.Auto(), SK
|
||||||
|
// automatically executes tool calls the LLM makes and feeds the results back,
|
||||||
|
// allowing the LLM to reason about the output and decide next steps (retry, fix,
|
||||||
|
// or respond to the user). This creates the agentic loop.
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.SemanticKernel;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Plugins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin that validates extracted key-value fields against the predefined schema.
|
||||||
|
/// The LLM calls this after extracting fields from natural language to check
|
||||||
|
/// whether all required fields are present and correctly typed.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtractionPlugin
|
||||||
|
{
|
||||||
|
// The required fields that must be non-null and non-empty for validation to pass.
|
||||||
|
// These match the required properties on ExtractedFields.
|
||||||
|
private static readonly string[] RequiredFields =
|
||||||
|
{ "Client", "Project", "Hours", "Rate", "Currency", "Date" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates extracted fields against the predefined schema.
|
||||||
|
/// Returns a JSON object indicating whether the extraction is valid
|
||||||
|
/// and listing any errors found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fieldsJson">
|
||||||
|
/// JSON string representing the extracted fields. Expected shape:
|
||||||
|
/// { "Client": "...", "Project": "...", "Hours": 3, ... }
|
||||||
|
/// </param>
|
||||||
|
/// <returns>JSON string with { "IsValid": bool, "Errors": [...] }</returns>
|
||||||
|
[KernelFunction("validate_extracted_fields")]
|
||||||
|
[Description("Validates extracted key-value fields against the required schema. " +
|
||||||
|
"Call this after extracting fields from natural language text to check " +
|
||||||
|
"that all required fields (Client, Project, Hours, Rate, Currency, Date) " +
|
||||||
|
"are present and correctly typed. Returns validation result with any errors.")]
|
||||||
|
public string ValidateExtractedFields(
|
||||||
|
[Description("JSON string of extracted fields")] string fieldsJson)
|
||||||
|
{
|
||||||
|
var result = new ValidationResult();
|
||||||
|
|
||||||
|
ExtractedFields? fields;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fields = JsonSerializer.Deserialize<ExtractedFields>(fieldsJson,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
result.IsValid = false;
|
||||||
|
result.Errors.Add($"Invalid JSON: {ex.Message}");
|
||||||
|
return JsonSerializer.Serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields == null)
|
||||||
|
{
|
||||||
|
result.IsValid = false;
|
||||||
|
result.Errors.Add("Deserialized fields object is null");
|
||||||
|
return JsonSerializer.Serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each required field for presence and non-empty value
|
||||||
|
if (string.IsNullOrWhiteSpace(fields.Client))
|
||||||
|
result.Errors.Add("Missing required field: Client");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fields.Project))
|
||||||
|
result.Errors.Add("Missing required field: Project");
|
||||||
|
|
||||||
|
if (fields.Hours == null || fields.Hours <= 0)
|
||||||
|
result.Errors.Add("Missing or invalid required field: Hours (must be a positive number)");
|
||||||
|
|
||||||
|
if (fields.Rate == null || fields.Rate <= 0)
|
||||||
|
result.Errors.Add("Missing or invalid required field: Rate (must be a positive number)");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fields.Currency))
|
||||||
|
result.Errors.Add("Missing required field: Currency");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fields.Date))
|
||||||
|
result.Errors.Add("Missing required field: Date");
|
||||||
|
|
||||||
|
result.IsValid = result.Errors.Count == 0;
|
||||||
|
return JsonSerializer.Serialize(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
// Program.cs -- ASP.NET Core Web API entry point for ChatAgent.
|
// Program.cs -- ASP.NET Core Web API entry point for ChatAgent.
|
||||||
//
|
//
|
||||||
|
// These using directives bring in the Semantic Kernel extension methods for DI registration.
|
||||||
|
// Without them, the AddOpenAIChatCompletion() and AddKernel() methods won't be found.
|
||||||
|
using Microsoft.SemanticKernel;
|
||||||
|
//
|
||||||
// This is the backend server. In Phase 1, it only serves a health check endpoint.
|
// This is the backend server. In Phase 1, it only serves a health check endpoint.
|
||||||
// In later phases, it will proxy OpenAI API calls (keeping the API key server-side)
|
// In later phases, it will proxy OpenAI API calls (keeping the API key server-side)
|
||||||
// and manage JSON file storage for conversation persistence.
|
// and manage JSON file storage for conversation persistence.
|
||||||
@@ -16,14 +20,43 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// for explicit structure -- each controller is a separate file with clear routing (D-05).
|
// for explicit structure -- each controller is a separate file with clear routing (D-05).
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
// Register a named HttpClient for proxying requests to the Responses API.
|
// --- Semantic Kernel Setup ---
|
||||||
// The base URL comes from appsettings.json (server-side config, not exposed to the browser).
|
//
|
||||||
// IHttpClientFactory manages the underlying HttpMessageHandler lifetime.
|
// Semantic Kernel (SK) is an AI orchestration framework from Microsoft. It provides:
|
||||||
builder.Services.AddHttpClient("ResponsesApi", client =>
|
// - Chat completion connectors (OpenAI, Azure OpenAI, etc.)
|
||||||
{
|
// - Plugin system for exposing C# methods as tools the LLM can call
|
||||||
var baseUrl = builder.Configuration["ResponsesApi:BaseUrl"] ?? "http://localhost:8317";
|
// - Automatic function calling (the LLM decides when to invoke tools)
|
||||||
client.BaseAddress = new Uri(baseUrl);
|
// - Streaming support for token-by-token delivery
|
||||||
});
|
//
|
||||||
|
// The "Kernel" is the central object: it holds the AI service, plugins, and configuration.
|
||||||
|
// We register it in DI so controllers can inject it.
|
||||||
|
|
||||||
|
// Read the CLIProxyAPI proxy URL and model from appsettings.json.
|
||||||
|
// The OpenAI connector works with any OpenAI-compatible API endpoint,
|
||||||
|
// so we point it at our local CLIProxyAPI proxy rather than OpenAI directly.
|
||||||
|
// IMPORTANT: The base URL must include "/v1" because the OpenAI SDK appends
|
||||||
|
// "chat/completions" directly to the base URL. Without "/v1", requests would
|
||||||
|
// hit "/chat/completions" instead of "/v1/chat/completions" and get a 404.
|
||||||
|
var responsesApiBaseUrl = builder.Configuration["ResponsesApi:BaseUrl"] ?? "http://localhost:8317/v1";
|
||||||
|
var model = builder.Configuration["ResponsesApi:Model"] ?? "claude-sonnet-4-6";
|
||||||
|
|
||||||
|
// AddOpenAIChatCompletion registers an IChatCompletionService in DI.
|
||||||
|
// The "endpoint" parameter lets us target any OpenAI-compatible API (here: CLIProxyAPI).
|
||||||
|
// The "apiKey" is required by the connector but CLIProxyAPI may not check it,
|
||||||
|
// so we use a placeholder. In production, this would be a real API key.
|
||||||
|
builder.Services.AddOpenAIChatCompletion(
|
||||||
|
modelId: model,
|
||||||
|
endpoint: new Uri(responsesApiBaseUrl),
|
||||||
|
apiKey: builder.Configuration["ResponsesApi:ApiKey"] ?? "not-needed");
|
||||||
|
|
||||||
|
// AddKernel() registers the Kernel class itself in DI. It automatically picks up
|
||||||
|
// any AI services (like the chat completion above) that are already registered.
|
||||||
|
builder.Services.AddKernel();
|
||||||
|
|
||||||
|
// Register the ExtractionPlugin so the Kernel can expose its [KernelFunction] methods
|
||||||
|
// as tools. When the LLM sees these tools, it can decide to call them during a conversation
|
||||||
|
// to validate extracted data. The plugin is registered as a singleton via DI.
|
||||||
|
builder.Services.AddSingleton<ChatAgent.Api.Plugins.ExtractionPlugin>();
|
||||||
|
|
||||||
// AddCors() registers Cross-Origin Resource Sharing services.
|
// AddCors() registers Cross-Origin Resource Sharing services.
|
||||||
// CORS is REQUIRED because the Blazor WASM client runs on a different origin
|
// CORS is REQUIRED because the Blazor WASM client runs on a different origin
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ResponsesApi": {
|
"ResponsesApi": {
|
||||||
"BaseUrl": "http://localhost:8317",
|
"BaseUrl": "http://localhost:8317/v1",
|
||||||
"Model": "claude-sonnet-4-6"
|
"Model": "claude-sonnet-4-6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/ChatAgent.Shared/Models/ExtractedFields.cs
Normal file
41
src/ChatAgent.Shared/Models/ExtractedFields.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// ExtractedFields.cs -- Strongly-typed schema for structured data extraction.
|
||||||
|
//
|
||||||
|
// This class defines the predefined set of key-value fields that the AI agent
|
||||||
|
// extracts from natural language input (e.g., email text). All fields are known
|
||||||
|
// at compile time. Required fields must be non-null for validation to pass.
|
||||||
|
//
|
||||||
|
// Placeholder fields are used until the real schema is provided.
|
||||||
|
|
||||||
|
namespace ChatAgent.Shared.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The fixed set of fields the agent extracts from natural language input.
|
||||||
|
/// Required fields are marked with comments; optional fields may be null.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtractedFields
|
||||||
|
{
|
||||||
|
/// <summary>Client or company name (required).</summary>
|
||||||
|
public string? Client { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Project or engagement name (required).</summary>
|
||||||
|
public string? Project { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of hours worked (required).</summary>
|
||||||
|
public decimal? Hours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Hourly rate (required).</summary>
|
||||||
|
public decimal? Rate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Currency code, e.g. "USD", "GBP" (required).</summary>
|
||||||
|
public string? Currency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Date of work or service (required). ISO 8601 format preferred.</summary>
|
||||||
|
public string? Date { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Description of work performed (optional).</summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Purchase order number (optional).</summary>
|
||||||
|
public string? PoNumber { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/ChatAgent.Shared/Models/ValidationResult.cs
Normal file
24
src/ChatAgent.Shared/Models/ValidationResult.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// ValidationResult.cs -- Result of validating extracted fields.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
namespace ChatAgent.Shared.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Describes whether extracted fields passed validation, and if not,
|
||||||
|
/// which specific errors were found.
|
||||||
|
/// </summary>
|
||||||
|
public class ValidationResult
|
||||||
|
{
|
||||||
|
/// <summary>True if all required fields are present and correctly typed.</summary>
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of validation error messages (e.g., "Missing required field: Client").
|
||||||
|
/// Empty when IsValid is true.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Errors { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.14" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.14" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Microsoft.SemanticKernel" Version="1.74.0" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ChatAgent.Shared.Models;
|
using ChatAgent.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.SemanticKernel;
|
||||||
|
using Microsoft.SemanticKernel.ChatCompletion;
|
||||||
|
using Microsoft.SemanticKernel.Connectors.OpenAI;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
namespace ChatAgent.Api.Tests;
|
namespace ChatAgent.Api.Tests;
|
||||||
|
|
||||||
@@ -20,15 +25,24 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostChat_StreamsTextDeltas_AndDone()
|
public async Task PostChat_StreamsTextDeltas_AndDone()
|
||||||
{
|
{
|
||||||
// Arrange: mock the upstream Responses API with canned SSE events
|
// Arrange: mock IChatCompletionService to return streaming text chunks
|
||||||
var sseContent = BuildSSE(
|
var mockChatService = new Mock<IChatCompletionService>();
|
||||||
("response.created", null),
|
|
||||||
("response.output_text.delta", "Hello"),
|
|
||||||
("response.output_text.delta", " world"),
|
|
||||||
("response.completed", null)
|
|
||||||
);
|
|
||||||
|
|
||||||
var client = CreateClientWithMockedUpstream(sseContent);
|
var chunks = new List<StreamingChatMessageContent>
|
||||||
|
{
|
||||||
|
new(AuthorRole.Assistant, "Hello"),
|
||||||
|
new(AuthorRole.Assistant, " world")
|
||||||
|
};
|
||||||
|
|
||||||
|
mockChatService
|
||||||
|
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||||
|
It.IsAny<ChatHistory>(),
|
||||||
|
It.IsAny<PromptExecutionSettings>(),
|
||||||
|
It.IsAny<Kernel>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(chunks.ToAsyncEnumerable());
|
||||||
|
|
||||||
|
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||||
|
|
||||||
var request = new ChatRequest
|
var request = new ChatRequest
|
||||||
{
|
{
|
||||||
@@ -54,16 +68,20 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostChat_HandlesUpstreamError_ReturnsErrorEvent()
|
public async Task PostChat_HandlesServiceError_ReturnsErrorEvent()
|
||||||
{
|
{
|
||||||
// Arrange: upstream returns 500
|
// Arrange: mock IChatCompletionService to throw HttpRequestException
|
||||||
var mockHandler = new MockHttpMessageHandler(
|
var mockChatService = new Mock<IChatCompletionService>();
|
||||||
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
|
||||||
{
|
|
||||||
Content = new StringContent("Internal Server Error")
|
|
||||||
});
|
|
||||||
|
|
||||||
var client = CreateClientWithHandler(mockHandler);
|
mockChatService
|
||||||
|
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||||
|
It.IsAny<ChatHistory>(),
|
||||||
|
It.IsAny<PromptExecutionSettings>(),
|
||||||
|
It.IsAny<Kernel>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(ThrowingAsyncEnumerable(new HttpRequestException("Connection refused")));
|
||||||
|
|
||||||
|
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||||
|
|
||||||
var request = new ChatRequest
|
var request = new ChatRequest
|
||||||
{
|
{
|
||||||
@@ -83,47 +101,75 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
Assert.Contains(events, e => e == "[DONE]");
|
Assert.Contains(events, e => e == "[DONE]");
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpClient CreateClientWithMockedUpstream(string sseContent)
|
private HttpClient CreateClientWithMockedChatService(IChatCompletionService chatService)
|
||||||
{
|
|
||||||
var mockHandler = new MockHttpMessageHandler(
|
|
||||||
new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent(sseContent, Encoding.UTF8, "text/event-stream")
|
|
||||||
});
|
|
||||||
|
|
||||||
return CreateClientWithHandler(mockHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpClient CreateClientWithHandler(MockHttpMessageHandler handler)
|
|
||||||
{
|
{
|
||||||
return _factory.WithWebHostBuilder(builder =>
|
return _factory.WithWebHostBuilder(builder =>
|
||||||
{
|
{
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
// Remove existing IHttpClientFactory registrations for "ResponsesApi"
|
// Remove existing IChatCompletionService registration and replace with mock
|
||||||
// and replace with our mock
|
var descriptor = services.SingleOrDefault(
|
||||||
services.AddHttpClient("ResponsesApi")
|
d => d.ServiceType == typeof(IChatCompletionService));
|
||||||
.ConfigurePrimaryHttpMessageHandler(() => handler);
|
if (descriptor != null)
|
||||||
|
services.Remove(descriptor);
|
||||||
|
|
||||||
|
services.AddSingleton(chatService);
|
||||||
});
|
});
|
||||||
}).CreateClient();
|
}).CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a fake SSE stream mimicking the Responses API format.
|
/// Creates an IAsyncEnumerable that throws the given exception when iterated.
|
||||||
|
/// Used to simulate service failures in streaming responses.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string BuildSSE(params (string type, string? delta)[] events)
|
private static async IAsyncEnumerable<StreamingChatMessageContent> ThrowingAsyncEnumerable(
|
||||||
|
Exception exception,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
await Task.CompletedTask;
|
||||||
foreach (var (type, delta) in events)
|
throw exception;
|
||||||
{
|
yield break; // Required to make the compiler treat this as an async enumerable
|
||||||
var data = delta != null
|
|
||||||
? $"{{\"type\":\"{type}\",\"delta\":\"{delta}\"}}"
|
|
||||||
: $"{{\"type\":\"{type}\"}}";
|
|
||||||
sb.AppendLine($"event: {type}");
|
|
||||||
sb.AppendLine($"data: {data}");
|
|
||||||
sb.AppendLine();
|
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostChat_ClarificationMessage_StreamedToClient()
|
||||||
|
{
|
||||||
|
// Arrange: simulate the LLM returning a clarification message
|
||||||
|
// (what happens after the agent exhausts tool call retries)
|
||||||
|
var mockChatService = new Mock<IChatCompletionService>();
|
||||||
|
|
||||||
|
var clarificationText = "I couldn't determine the currency. Could you specify whether this is USD or GBP?";
|
||||||
|
var chunks = new List<StreamingChatMessageContent>
|
||||||
|
{
|
||||||
|
new(AuthorRole.Assistant, clarificationText)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockChatService
|
||||||
|
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||||
|
It.IsAny<ChatHistory>(),
|
||||||
|
It.IsAny<PromptExecutionSettings>(),
|
||||||
|
It.IsAny<Kernel>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(chunks.ToAsyncEnumerable());
|
||||||
|
|
||||||
|
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Invoice for 3 hours consulting" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync("/api/chat", request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
var events = ParseSSEData(body);
|
||||||
|
|
||||||
|
// Assert: clarification message streamed as text, not swallowed
|
||||||
|
Assert.Contains(events, e => e.Contains("currency"));
|
||||||
|
Assert.Contains(events, e => e == "[DONE]");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -137,22 +183,3 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple mock HttpMessageHandler that returns a canned response.
|
|
||||||
/// </summary>
|
|
||||||
public class MockHttpMessageHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
private readonly HttpResponseMessage _response;
|
|
||||||
|
|
||||||
public MockHttpMessageHandler(HttpResponseMessage response)
|
|
||||||
{
|
|
||||||
_response = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(_response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
144
tests/ChatAgent.Api.Tests/ChatIntegrationTests.cs
Normal file
144
tests/ChatAgent.Api.Tests/ChatIntegrationTests.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests that hit the real CLIProxyAPI proxy at localhost:8317.
|
||||||
|
/// These tests are skipped when CLIProxyAPI is not reachable, so they won't
|
||||||
|
/// break CI or local runs without the proxy running.
|
||||||
|
///
|
||||||
|
/// To run: start CLIProxyAPI on port 8317, then run tests normally.
|
||||||
|
/// Skipped tests show as "(Skipped)" in test output with a reason message.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
|
// Check once per test class whether CLIProxyAPI is reachable
|
||||||
|
private static readonly Lazy<bool> _liteLlmAvailable = new(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
|
||||||
|
var response = client.GetAsync("http://localhost:8317/health").Result;
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
public ChatIntegrationTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostChat_RealLLM_StreamsResponseAndCompletes()
|
||||||
|
{
|
||||||
|
if (!_liteLlmAvailable.Value)
|
||||||
|
{
|
||||||
|
// CLIProxyAPI not reachable — skip gracefully rather than fail
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrange: use the real app with no mocks — SK talks to CLIProxyAPI
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Reply with exactly: hello" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync("/api/chat", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
var events = ParseSSEData(body);
|
||||||
|
|
||||||
|
// Assert: should have at least one text delta and a [DONE] signal
|
||||||
|
Assert.Contains(events, e => e.Contains("\"text\""));
|
||||||
|
Assert.Contains(events, e => e == "[DONE]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostChat_RealLLM_MultiTurnConversation()
|
||||||
|
{
|
||||||
|
if (!_liteLlmAvailable.Value)
|
||||||
|
{
|
||||||
|
// CLIProxyAPI not reachable — skip gracefully rather than fail
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
// First turn
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Remember the word 'banana'. Just say OK." }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response1 = await client.PostAsJsonAsync("/api/chat", request);
|
||||||
|
var body1 = await response1.Content.ReadAsStringAsync();
|
||||||
|
var events1 = ParseSSEData(body1);
|
||||||
|
Assert.Contains(events1, e => e == "[DONE]");
|
||||||
|
|
||||||
|
// Collect first response text
|
||||||
|
var firstResponse = string.Join("", events1
|
||||||
|
.Where(e => e != "[DONE]" && !e.Contains("\"error\""))
|
||||||
|
.Select(e =>
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(e);
|
||||||
|
return doc.RootElement.GetProperty("text").GetString() ?? "";
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Second turn — asks about the remembered word
|
||||||
|
var request2 = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Remember the word 'banana'. Just say OK." },
|
||||||
|
new() { Role = "assistant", Content = firstResponse },
|
||||||
|
new() { Role = "user", Content = "What word did I ask you to remember?" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response2 = await client.PostAsJsonAsync("/api/chat", request2);
|
||||||
|
var body2 = await response2.Content.ReadAsStringAsync();
|
||||||
|
var events2 = ParseSSEData(body2);
|
||||||
|
|
||||||
|
// Assert: response should mention banana
|
||||||
|
var secondResponse = string.Join("", events2
|
||||||
|
.Where(e => e != "[DONE]" && !e.Contains("\"error\""))
|
||||||
|
.Select(e =>
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(e);
|
||||||
|
return doc.RootElement.GetProperty("text").GetString() ?? "";
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Contains("banana", secondResponse, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(events2, e => e == "[DONE]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseSSEData(string sseText)
|
||||||
|
{
|
||||||
|
return sseText.Split('\n')
|
||||||
|
.Where(line => line.StartsWith("data: "))
|
||||||
|
.Select(line => line.Substring(6).Trim())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs
Normal file
106
tests/ChatAgent.Api.Tests/ExtractionPluginTests.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Api.Plugins;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Tests;
|
||||||
|
|
||||||
|
public class ExtractionPluginTests
|
||||||
|
{
|
||||||
|
private readonly ExtractionPlugin _plugin = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateExtractedFields_AllRequiredPresent_ReturnsValid()
|
||||||
|
{
|
||||||
|
var fields = new ExtractedFields
|
||||||
|
{
|
||||||
|
Client = "Acme Corp",
|
||||||
|
Project = "Phase 2",
|
||||||
|
Hours = 3,
|
||||||
|
Rate = 150,
|
||||||
|
Currency = "USD",
|
||||||
|
Date = "2026-04-01"
|
||||||
|
};
|
||||||
|
|
||||||
|
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||||
|
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateExtractedFields_MissingRequired_ReturnsErrors()
|
||||||
|
{
|
||||||
|
// Missing Client and Hours
|
||||||
|
var fields = new ExtractedFields
|
||||||
|
{
|
||||||
|
Project = "Phase 2",
|
||||||
|
Rate = 150,
|
||||||
|
Currency = "USD",
|
||||||
|
Date = "2026-04-01"
|
||||||
|
};
|
||||||
|
|
||||||
|
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||||
|
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains(result.Errors, e => e.Contains("Client"));
|
||||||
|
Assert.Contains(result.Errors, e => e.Contains("Hours"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateExtractedFields_InvalidJson_ReturnsError()
|
||||||
|
{
|
||||||
|
var resultJson = _plugin.ValidateExtractedFields("not valid json");
|
||||||
|
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains(result.Errors, e => e.Contains("Invalid JSON"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateExtractedFields_ZeroHours_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 result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains(result.Errors, e => e.Contains("Hours"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateExtractedFields_OptionalFieldsMissing_StillValid()
|
||||||
|
{
|
||||||
|
// Description and PoNumber are optional
|
||||||
|
var fields = new ExtractedFields
|
||||||
|
{
|
||||||
|
Client = "Acme Corp",
|
||||||
|
Project = "Phase 2",
|
||||||
|
Hours = 3,
|
||||||
|
Rate = 150,
|
||||||
|
Currency = "USD",
|
||||||
|
Date = "2026-04-01"
|
||||||
|
// Description and PoNumber intentionally omitted
|
||||||
|
};
|
||||||
|
|
||||||
|
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||||
|
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user