feat: add extract-feature and export-spec portability skills

Two new OpenSpec skills for porting features to sandboxed codebases:
- /opsx:extract-feature generates minimal, printable code recipes
- /opsx:export-spec generates compact specs for AI-assisted reimplementation
Both support cumulative dependency analysis across archived changes.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
local
2026-04-05 00:59:06 +01:00
parent 471e9ce935
commit d3300c7db9
7 changed files with 1021 additions and 0 deletions

View File

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

View File

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

View File

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