diff --git a/.claude/commands/opsx/export-spec.md b/.claude/commands/opsx/export-spec.md new file mode 100644 index 0000000..e006f18 --- /dev/null +++ b/.claude/commands/opsx/export-spec.md @@ -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: + ## Target: () + + ## Packages + + + ## Architecture + <2-3 sentence overview> + + ## Components + ### : + - + - + - + + ## Contracts + + + ## Wiring + + + ## Behavior + + ``` + + **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/-openspec.md` + +8. **Write the output** + + Save to: `openspec/exports/-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 diff --git a/.claude/commands/opsx/extract-feature.md b/.claude/commands/opsx/extract-feature.md new file mode 100644 index 0000000..a009d91 --- /dev/null +++ b/.claude/commands/opsx/extract-feature.md @@ -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/-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 diff --git a/.claude/skills/openspec-export-spec/SKILL.md b/.claude/skills/openspec-export-spec/SKILL.md new file mode 100644 index 0000000..aedcdbc --- /dev/null +++ b/.claude/skills/openspec-export-spec/SKILL.md @@ -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: + ## Target: () + + ## Packages + + + ## Architecture + <2-3 sentence overview of how the pieces fit together> + + ## Components + + ### : + - + - + - + + ### : + ... + + ## Contracts + + + ## Wiring + + + ## Behavior + + ``` + + **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/-spec.md` + If OpenSpec variant: `openspec/exports/-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) + +### ChatRequest/ChatMessage: Shared/Models/ +- ChatRequest: Messages(List) +- ChatMessage: Role(string), Content(string) + +## Wiring (Program.cs, add after AddControllers) +- AddOpenAIChatCompletion(model, endpoint with /v1 suffix, apiKey) +- AddKernel() +- AddSingleton() +- 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 diff --git a/.claude/skills/openspec-extract-feature/SKILL.md b/.claude/skills/openspec-extract-feature/SKILL.md new file mode 100644 index 0000000..49e8bcc --- /dev/null +++ b/.claude/skills/openspec-extract-feature/SKILL.md @@ -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/-recipe.md` + + Also display the full content so the user can review it immediately. + +**Output Format** + +```markdown +# Feature Recipe: +**Source**: | **Lines to type**: ~ + +## Prerequisites +- `Microsoft.SemanticKernel` 1.74.0 +- `Microsoft.SemanticKernel.Connectors.OpenAI` 1.74.0 + +## Step 1: (~N lines) + +```csharp + +``` + +## Step 2: (~N lines) + +Insert after ``: +```csharp + +``` + +## Domain-Specific (adapt these) + +### ExtractionPlugin.cs +```csharp +// ADAPT: Replace field names and validation logic for your domain + +``` +``` + +**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 diff --git a/openspec/exports/migrate-to-semantic-kernel-openspec.md b/openspec/exports/migrate-to-semantic-kernel-openspec.md new file mode 100644 index 0000000..8230e58 --- /dev/null +++ b/openspec/exports/migrate-to-semantic-kernel-openspec.md @@ -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) +- [ ] 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) + +## 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() + +## 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 diff --git a/openspec/exports/migrate-to-semantic-kernel-recipe.md b/openspec/exports/migrate-to-semantic-kernel-recipe.md new file mode 100644 index 0000000..f6cdffe --- /dev/null +++ b/openspec/exports/migrate-to-semantic-kernel-recipe.md @@ -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 + + +``` + +--- + +## 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 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 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(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(); + 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(); + _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()` diff --git a/openspec/exports/migrate-to-semantic-kernel-spec.md b/openspec/exports/migrate-to-semantic-kernel-spec.md new file mode 100644 index 0000000..fddb2d7 --- /dev/null +++ b/openspec/exports/migrate-to-semantic-kernel-spec.md @@ -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 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) + +### ChatRequest + ChatMessage: Shared/Models/ +- ChatRequest: Messages(List) +- 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() +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