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:
251
openspec/exports/migrate-to-semantic-kernel-recipe.md
Normal file
251
openspec/exports/migrate-to-semantic-kernel-recipe.md
Normal 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()`
|
||||
Reference in New Issue
Block a user