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

View File

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

View File

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

View File

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

View File

@@ -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