2
0

refactor(ai): migrate AI services to structured output plugins
All checks were successful
Build and Test / build (push) Successful in 18s
Release / build (push) Successful in 35s

Convert all AI services to use plugin-based structured output:
- Create dedicated plugins for code intelligence, review, docs, issues, and workflows
- Replace JSON parsing with SendForStructuredOutputAsync
- Add PluginHelpers for consistent deserialization
- Remove inline prompt instructions in favor of plugin definitions
- Eliminate brittle JSON parsing and error handling
- Improve type safety and maintainability across all AI features

Affected services: CodeIntelligence, CodeReview, Documentation, Issue, Workflow inspection
This commit is contained in:
2026-03-07 16:10:56 -05:00
parent 1385cbafa9
commit a55aa5f717
11 changed files with 998 additions and 215 deletions

View File

@@ -4,6 +4,7 @@
using GitCaddy.AI.Service.Services;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Licensing;
using GitCaddy.AI.Service.Plugins;
using MarketAlly.AIPlugin.Conversation;
using Microsoft.AspNetCore.Mvc;
@@ -429,15 +430,8 @@ public class AIController : ControllerBase
- Performance issues (unnecessary steps, missing caching)
- Compatibility issues with the available runner labels
- Best practice violations
Respond with a JSON object containing:
{
"valid": true/false,
"issues": [{"line": 0, "severity": "error|warning|info", "message": "...", "fix": "..."}],
"suggestions": ["..."]
}
Only respond with the JSON object, no other text.
""")
.RegisterPlugin<InspectWorkflowPlugin>()
.Build();
var prompt = $"Workflow file: {request.FilePath}\n\n```yaml\n{request.Content}\n```";
@@ -446,45 +440,26 @@ public class AIController : ControllerBase
prompt += $"\n\nAvailable runner labels: {string.Join(", ", request.RunnerLabels)}";
}
var aiResponse = await conversation.SendAsync(prompt, cancellationToken);
var responseText = aiResponse.FinalMessage ?? "{}";
var aiResponse = await conversation.SendForStructuredOutputAsync<InspectWorkflowPluginResult>(
prompt, "InspectWorkflow", cancellationToken);
// Parse the AI response as JSON
try
{
var result = System.Text.Json.JsonSerializer.Deserialize<InspectWorkflowResult>(
responseText,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var result = PluginHelpers.DeserializeOutput<InspectWorkflowPluginResult>(aiResponse.StructuredOutput);
return Ok(new
{
valid = result?.Valid ?? true,
issues = result?.Issues?.Select(i => new
{
line = i.Line,
severity = i.Severity,
message = i.Message,
fix = i.Fix
}).ToList() ?? [],
suggestions = result?.Suggestions ?? [],
confidence = 0.8,
input_tokens = 0,
output_tokens = 0
});
}
catch (System.Text.Json.JsonException)
return Ok(new
{
// If AI didn't return valid JSON, wrap the text response
return Ok(new
valid = result.Valid,
issues = result.Issues?.Select(i => new
{
valid = true,
issues = Array.Empty<object>(),
suggestions = new[] { responseText },
confidence = 0.5,
input_tokens = 0,
output_tokens = 0
});
}
line = i.Line,
severity = i.Severity,
message = i.Message,
fix = i.Fix
}).ToList() ?? [],
suggestions = result.Suggestions ?? [],
confidence = 0.8,
input_tokens = 0,
output_tokens = 0
});
}
catch (Exception ex)
{
@@ -722,18 +697,3 @@ public class InspectWorkflowDto
public List<string>? RunnerLabels { get; set; }
}
// Internal model for parsing AI response
internal class InspectWorkflowResult
{
public bool Valid { get; set; }
public List<InspectWorkflowIssue>? Issues { get; set; }
public List<string>? Suggestions { get; set; }
}
internal class InspectWorkflowIssue
{
public int Line { get; set; }
public string? Severity { get; set; }
public string? Message { get; set; }
public string? Fix { get; set; }
}

View File

@@ -0,0 +1,162 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class SummarizeChangesResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("bullet_points")]
public List<string> BulletPoints { get; set; }
[JsonPropertyName("impact_assessment")]
public string ImpactAssessment { get; set; }
}
public class CodeReferenceItem
{
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}
public class ExplainCodeResult
{
[JsonPropertyName("explanation")]
public string Explanation { get; set; }
[JsonPropertyName("key_concepts")]
public List<string> KeyConcepts { get; set; }
[JsonPropertyName("references")]
public List<CodeReferenceItem> References { get; set; }
}
public class SuggestFixResult
{
[JsonPropertyName("explanation")]
public string Explanation { get; set; }
[JsonPropertyName("suggested_code")]
public string SuggestedCode { get; set; }
[JsonPropertyName("alternative_fixes")]
public List<string> AlternativeFixes { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("SummarizeChanges",
"Summarize code changes with a brief paragraph, key bullet points, " +
"and an impact assessment.")]
public class SummarizeChangesPlugin : IAIPlugin
{
[AIParameter("Brief summary paragraph describing the overall changes", required: true)]
public string Summary { get; set; }
[AIParameter("Key changes as a list of concise bullet point strings", required: true)]
public List<string> BulletPoints { get; set; }
[AIParameter("Assessment of the potential impact of these changes", required: true)]
public string ImpactAssessment { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["bulletpoints"] = typeof(List<string>),
["impactassessment"] = typeof(string)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SummarizeChangesResult
{
Summary = Summary,
BulletPoints = BulletPoints ?? [],
ImpactAssessment = ImpactAssessment ?? ""
};
return Task.FromResult(new AIPluginResult(result, "Changes summarized"));
}
}
[AIPlugin("ExplainCode",
"Explain what code does, including key concepts, patterns used, " +
"and relevant documentation references.")]
public class ExplainCodePlugin : IAIPlugin
{
[AIParameter("Clear explanation of what the code does, how it works, and any notable patterns", required: true)]
public string Explanation { get; set; }
[AIParameter("Key programming concepts and patterns found in the code, as a list of strings", required: true)]
public List<string> KeyConcepts { get; set; }
[AIParameter("Relevant documentation references. Each item must have 'description' (string) and 'url' (string). Use empty array if no relevant references.", required: true)]
public List<CodeReferenceItem> References { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["explanation"] = typeof(string),
["keyconcepts"] = typeof(List<string>),
["references"] = typeof(List<CodeReferenceItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ExplainCodeResult
{
Explanation = Explanation,
KeyConcepts = KeyConcepts ?? [],
References = References ?? []
};
return Task.FromResult(new AIPluginResult(result, "Code explained"));
}
}
[AIPlugin("SuggestFix",
"Analyze code with an error and suggest a fix, including an explanation " +
"of the cause, corrected code, and alternative solutions.")]
public class SuggestFixPlugin : IAIPlugin
{
[AIParameter("Explanation of what is causing the error", required: true)]
public string Explanation { get; set; }
[AIParameter("The corrected code that fixes the error", required: true)]
public string SuggestedCode { get; set; }
[AIParameter("Alternative fix approaches as a list of description strings", required: true)]
public List<string> AlternativeFixes { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["explanation"] = typeof(string),
["suggestedcode"] = typeof(string),
["alternativefixes"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SuggestFixResult
{
Explanation = Explanation,
SuggestedCode = SuggestedCode ?? "",
AlternativeFixes = AlternativeFixes ?? []
};
return Task.FromResult(new AIPluginResult(result, "Fix suggested"));
}
}

View File

@@ -0,0 +1,192 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class ReviewCommentItem
{
[JsonPropertyName("path")]
public string Path { get; set; }
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("end_line")]
public int EndLine { get; set; }
[JsonPropertyName("body")]
public string Body { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("category")]
public string Category { get; set; }
[JsonPropertyName("suggested_fix")]
public string SuggestedFix { get; set; }
}
public class SecurityIssueItem
{
[JsonPropertyName("path")]
public string Path { get; set; }
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("issue_type")]
public string IssueType { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("remediation")]
public string Remediation { get; set; }
}
public class ReviewPullRequestResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("verdict")]
public string Verdict { get; set; }
[JsonPropertyName("comments")]
public List<ReviewCommentItem> Comments { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
[JsonPropertyName("security_issues")]
public List<SecurityIssueItem> SecurityIssues { get; set; }
[JsonPropertyName("security_risk_score")]
public int SecurityRiskScore { get; set; }
[JsonPropertyName("security_summary")]
public string SecuritySummary { get; set; }
[JsonPropertyName("estimated_review_minutes")]
public int EstimatedReviewMinutes { get; set; }
}
public class ReviewCommitResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("comments")]
public List<ReviewCommentItem> Comments { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("ReviewPullRequest",
"Review a pull request and provide structured feedback including a verdict, " +
"line-by-line comments with severity levels, security analysis, and improvement suggestions.")]
public class ReviewPullRequestPlugin : IAIPlugin
{
[AIParameter("Brief overview of the changes and overall assessment", required: true)]
public string Summary { get; set; }
[AIParameter("Review verdict: must be exactly 'approve', 'request_changes', or 'comment'", required: true)]
public string Verdict { get; set; }
[AIParameter("Line-by-line review comments. Each item must have 'path' (string, file path), 'line' (int), 'end_line' (int, 0 if single line), 'body' (string, the comment), 'severity' (string: 'info', 'warning', 'error', or 'critical'), 'category' (string: 'security', 'performance', 'style', 'bug', etc.), 'suggested_fix' (string, code fix or empty)", required: true)]
public List<ReviewCommentItem> Comments { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
[AIParameter("Security issues found. Each item must have 'path' (string), 'line' (int), 'issue_type' (string: 'sql_injection', 'xss', 'hardcoded_secret', etc.), 'description' (string), 'severity' (string), 'remediation' (string). Use empty array if no issues.", required: true)]
public List<SecurityIssueItem> SecurityIssues { get; set; }
[AIParameter("Security risk score from 0 (no risk) to 100 (critical risk)", required: true)]
public int SecurityRiskScore { get; set; }
[AIParameter("Brief summary of security analysis findings", required: true)]
public string SecuritySummary { get; set; }
[AIParameter("Estimated time in minutes to review these changes", required: true)]
public int EstimatedReviewMinutes { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["verdict"] = typeof(string),
["comments"] = typeof(List<ReviewCommentItem>),
["suggestions"] = typeof(List<string>),
["securityissues"] = typeof(List<SecurityIssueItem>),
["securityriskscore"] = typeof(int),
["securitysummary"] = typeof(string),
["estimatedreviewminutes"] = typeof(int)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ReviewPullRequestResult
{
Summary = Summary,
Verdict = Verdict,
Comments = Comments ?? [],
Suggestions = Suggestions ?? [],
SecurityIssues = SecurityIssues ?? [],
SecurityRiskScore = SecurityRiskScore,
SecuritySummary = SecuritySummary ?? "",
EstimatedReviewMinutes = EstimatedReviewMinutes
};
return Task.FromResult(new AIPluginResult(result, "Pull request review complete"));
}
}
[AIPlugin("ReviewCommit",
"Review a single commit and provide structured feedback including comments " +
"with severity levels and improvement suggestions.")]
public class ReviewCommitPlugin : IAIPlugin
{
[AIParameter("Brief overview of the commit and overall assessment", required: true)]
public string Summary { get; set; }
[AIParameter("Line-by-line review comments. Each item must have 'path' (string), 'line' (int), 'end_line' (int, 0 if single line), 'body' (string), 'severity' (string: 'info', 'warning', 'error', or 'critical'), 'category' (string), 'suggested_fix' (string or empty)", required: true)]
public List<ReviewCommentItem> Comments { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["comments"] = typeof(List<ReviewCommentItem>),
["suggestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ReviewCommitResult
{
Summary = Summary,
Comments = Comments ?? [],
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Commit review complete"));
}
}

View File

@@ -0,0 +1,100 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class DocumentationSectionItem
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
}
public class GenerateDocumentationResult
{
[JsonPropertyName("documentation")]
public string Documentation { get; set; }
[JsonPropertyName("sections")]
public List<DocumentationSectionItem> Sections { get; set; }
}
public class GenerateCommitMessageResult
{
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("alternatives")]
public List<string> Alternatives { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("GenerateDocumentation",
"Generate comprehensive code documentation including a full documentation " +
"string and organized sections for different aspects of the code.")]
public class GenerateDocumentationPlugin : IAIPlugin
{
[AIParameter("The complete documentation text in the requested format (jsdoc, docstring, xml, or markdown)", required: true)]
public string Documentation { get; set; }
[AIParameter("Documentation broken into sections. Each item must have 'title' (string, section heading like 'Description', 'Parameters', 'Returns', 'Examples') and 'content' (string, section body)", required: true)]
public List<DocumentationSectionItem> Sections { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["documentation"] = typeof(string),
["sections"] = typeof(List<DocumentationSectionItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateDocumentationResult
{
Documentation = Documentation,
Sections = Sections ?? []
};
return Task.FromResult(new AIPluginResult(result, "Documentation generated"));
}
}
[AIPlugin("GenerateCommitMessage",
"Generate a commit message for code changes, including a primary message " +
"and 2-3 alternative options.")]
public class GenerateCommitMessagePlugin : IAIPlugin
{
[AIParameter("The primary commit message following the requested style", required: true)]
public string Message { get; set; }
[AIParameter("2-3 alternative commit message options", required: true)]
public List<string> Alternatives { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["message"] = typeof(string),
["alternatives"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateCommitMessageResult
{
Message = Message,
Alternatives = Alternatives ?? []
};
return Task.FromResult(new AIPluginResult(result, "Commit message generated"));
}
}

View File

@@ -0,0 +1,173 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class TriageIssueResult
{
[JsonPropertyName("priority")]
public string Priority { get; set; }
[JsonPropertyName("category")]
public string Category { get; set; }
[JsonPropertyName("suggested_labels")]
public List<string> SuggestedLabels { get; set; }
[JsonPropertyName("suggested_assignees")]
public List<string> SuggestedAssignees { get; set; }
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("is_duplicate")]
public bool IsDuplicate { get; set; }
[JsonPropertyName("duplicate_of")]
public long DuplicateOf { get; set; }
}
public class LabelSuggestionItem
{
[JsonPropertyName("label")]
public string Label { get; set; }
[JsonPropertyName("confidence")]
public float Confidence { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; }
}
public class SuggestLabelsResult
{
[JsonPropertyName("suggestions")]
public List<LabelSuggestionItem> Suggestions { get; set; }
}
public class GenerateIssueResponseResult
{
[JsonPropertyName("response")]
public string Response { get; set; }
[JsonPropertyName("follow_up_questions")]
public List<string> FollowUpQuestions { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("TriageIssue",
"Triage a software issue by determining its priority, category, " +
"suggested labels, and whether it is a duplicate.")]
public class TriageIssuePlugin : IAIPlugin
{
[AIParameter("Issue priority: must be exactly 'critical', 'high', 'medium', or 'low'", required: true)]
public string Priority { get; set; }
[AIParameter("Issue category: e.g. 'bug', 'feature', 'question', 'docs', 'enhancement'", required: true)]
public string Category { get; set; }
[AIParameter("Suggested labels from the available labels list", required: true)]
public List<string> SuggestedLabels { get; set; }
[AIParameter("Suggested assignees (usernames) who might be best to handle this. Use empty array if unknown.", required: true)]
public List<string> SuggestedAssignees { get; set; }
[AIParameter("Brief summary of the issue and recommended actions", required: true)]
public string Summary { get; set; }
[AIParameter("Whether this issue appears to be a duplicate of an existing issue", required: true)]
public bool IsDuplicate { get; set; }
[AIParameter("Issue ID of the original if this is a duplicate, or 0 if not a duplicate", required: true)]
public long DuplicateOf { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["priority"] = typeof(string),
["category"] = typeof(string),
["suggestedlabels"] = typeof(List<string>),
["suggestedassignees"] = typeof(List<string>),
["summary"] = typeof(string),
["isduplicate"] = typeof(bool),
["duplicateof"] = typeof(long)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new TriageIssueResult
{
Priority = Priority,
Category = Category,
SuggestedLabels = SuggestedLabels ?? [],
SuggestedAssignees = SuggestedAssignees ?? [],
Summary = Summary,
IsDuplicate = IsDuplicate,
DuplicateOf = DuplicateOf
};
return Task.FromResult(new AIPluginResult(result, "Issue triaged"));
}
}
[AIPlugin("SuggestLabels",
"Suggest appropriate labels for an issue from the available label options, " +
"with confidence scores and reasoning.")]
public class SuggestLabelsPlugin : IAIPlugin
{
[AIParameter("Label suggestions. Each item must have 'label' (string, from available labels), 'confidence' (float, 0.0-1.0), 'reason' (string, why this label applies)", required: true)]
public List<LabelSuggestionItem> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["suggestions"] = typeof(List<LabelSuggestionItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SuggestLabelsResult
{
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Labels suggested"));
}
}
[AIPlugin("GenerateIssueResponse",
"Generate a professional response to a software issue, " +
"including optional follow-up questions for clarification.")]
public class GenerateIssueResponsePlugin : IAIPlugin
{
[AIParameter("The response text to post as a comment on the issue", required: true)]
public string Response { get; set; }
[AIParameter("Follow-up questions to ask the reporter for clarification. Use empty array if none needed.", required: true)]
public List<string> FollowUpQuestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["response"] = typeof(string),
["followupquestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateIssueResponseResult
{
Response = Response,
FollowUpQuestions = FollowUpQuestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Issue response generated"));
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// Shared helpers for working with AIPlugin structured output.
/// </summary>
public static class PluginHelpers
{
/// <summary>
/// Safely deserializes StructuredOutput to the target type, handling the various
/// runtime types that the framework may return (typed object, JsonElement, or string).
/// </summary>
public static T DeserializeOutput<T>(object? structuredOutput) where T : class, new()
{
if (structuredOutput is T typed)
return typed;
string json;
if (structuredOutput is string str)
json = str;
else if (structuredOutput is JsonElement jsonElement)
json = jsonElement.GetRawText();
else if (structuredOutput != null)
json = JsonSerializer.Serialize(structuredOutput, structuredOutput.GetType());
else
return new T();
return JsonSerializer.Deserialize<T>(json) ?? new T();
}
}

View File

@@ -0,0 +1,76 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class WorkflowIssueItem
{
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("fix")]
public string Fix { get; set; }
}
public class InspectWorkflowPluginResult
{
[JsonPropertyName("valid")]
public bool Valid { get; set; }
[JsonPropertyName("issues")]
public List<WorkflowIssueItem> Issues { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
}
// ============================================================================
// Plugin
// ============================================================================
[AIPlugin("InspectWorkflow",
"Inspect a CI/CD workflow YAML file for issues including syntax errors, " +
"missing fields, security problems, performance issues, and best practice violations.")]
public class InspectWorkflowPlugin : IAIPlugin
{
[AIParameter("Whether the workflow file is valid (no errors found)", required: true)]
public bool Valid { get; set; }
[AIParameter("Issues found in the workflow. Each item must have 'line' (int, line number or 0), 'severity' (string: 'error', 'warning', or 'info'), 'message' (string, description of the issue), 'fix' (string, suggested fix)", required: true)]
public List<WorkflowIssueItem> Issues { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["valid"] = typeof(bool),
["issues"] = typeof(List<WorkflowIssueItem>),
["suggestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new InspectWorkflowPluginResult
{
Valid = Valid,
Issues = Issues ?? [],
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Workflow inspection complete"));
}
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered code intelligence.
/// Implementation of AI-powered code intelligence using MarketAlly.AIPlugin structured output.
/// </summary>
public class CodeIntelligenceService : ICodeIntelligenceService
{
@@ -33,16 +34,28 @@ public class CodeIntelligenceService : ICodeIntelligenceService
.WithSystemPrompt("""
You are an expert at summarizing code changes.
Provide clear, concise summaries that help developers understand what changed and why.
Output a brief summary paragraph followed by bullet points of key changes.
Also assess the potential impact of these changes.
""")
.RegisterPlugin<SummarizeChangesPlugin>()
.Build();
var prompt = BuildSummarizePrompt(request);
var response = await conversation.SendAsync(prompt, cancellationToken);
return ParseSummarizeResponse(response.FinalMessage ?? "");
var response = await conversation.SendForStructuredOutputAsync<SummarizeChangesResult>(
prompt, "SummarizeChanges", cancellationToken);
var result = PluginHelpers.DeserializeOutput<SummarizeChangesResult>(response.StructuredOutput);
var protoResponse = new SummarizeChangesResponse
{
Summary = result.Summary ?? "",
ImpactAssessment = result.ImpactAssessment ?? ""
};
if (result.BulletPoints != null)
protoResponse.BulletPoints.AddRange(result.BulletPoints);
return protoResponse;
}
public async Task<ExplainCodeResponse> ExplainCodeAsync(
@@ -60,6 +73,7 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Adjust your explanation depth based on the code complexity.
""")
.RegisterPlugin<ExplainCodePlugin>()
.Build();
var prompt = $"""
@@ -72,12 +86,32 @@ public class CodeIntelligenceService : ICodeIntelligenceService
{(string.IsNullOrEmpty(request.Question) ? "" : $"Specific question: {request.Question}")}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<ExplainCodeResult>(
prompt, "ExplainCode", cancellationToken);
return new ExplainCodeResponse
var result = PluginHelpers.DeserializeOutput<ExplainCodeResult>(response.StructuredOutput);
var protoResponse = new ExplainCodeResponse
{
Explanation = response.FinalMessage ?? ""
Explanation = result.Explanation ?? ""
};
if (result.KeyConcepts != null)
protoResponse.KeyConcepts.AddRange(result.KeyConcepts);
if (result.References != null)
{
foreach (var r in result.References)
{
protoResponse.References.Add(new CodeReference
{
Description = r.Description ?? "",
Url = r.Url ?? ""
});
}
}
return protoResponse;
}
public async Task<SuggestFixResponse> SuggestFixAsync(
@@ -94,6 +128,7 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Be specific and provide working code.
""")
.RegisterPlugin<SuggestFixPlugin>()
.Build();
var prompt = $"""
@@ -111,12 +146,21 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Please suggest a fix.
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<SuggestFixResult>(
prompt, "SuggestFix", cancellationToken);
return new SuggestFixResponse
var result = PluginHelpers.DeserializeOutput<SuggestFixResult>(response.StructuredOutput);
var protoResponse = new SuggestFixResponse
{
Explanation = response.FinalMessage ?? ""
Explanation = result.Explanation ?? "",
SuggestedCode = result.SuggestedCode ?? ""
};
if (result.AlternativeFixes != null)
protoResponse.AlternativeFixes.AddRange(result.AlternativeFixes);
return protoResponse;
}
private static string BuildSummarizePrompt(SummarizeChangesRequest request)
@@ -149,25 +193,4 @@ public class CodeIntelligenceService : ICodeIntelligenceService
return sb.ToString();
}
private static SummarizeChangesResponse ParseSummarizeResponse(string response)
{
var result = new SummarizeChangesResponse
{
Summary = response
};
// Extract bullet points (lines starting with - or *)
var lines = response.Split('\n');
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
{
result.BulletPoints.Add(trimmed[2..]);
}
}
return result;
}
}

View File

@@ -3,14 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using MarketAlly.AIPlugin;
using MarketAlly.AIPlugin.Conversation;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered code review using MarketAlly.AIPlugin.
/// Implementation of AI-powered code review using MarketAlly.AIPlugin structured output.
/// </summary>
public class CodeReviewService : ICodeReviewService
{
@@ -33,16 +32,16 @@ public class CodeReviewService : ICodeReviewService
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt(GetCodeReviewSystemPrompt(request.Options))
.RegisterPlugin<ReviewPullRequestPlugin>()
.Build();
// Build the review prompt
var prompt = BuildPullRequestReviewPrompt(request);
// Get AI response
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<ReviewPullRequestResult>(
prompt, "ReviewPullRequest", cancellationToken);
// Parse and return structured response
return ParsePullRequestReviewResponse(response.FinalMessage ?? "");
var result = PluginHelpers.DeserializeOutput<ReviewPullRequestResult>(response.StructuredOutput);
return MapPullRequestResult(result);
}
public async Task<ReviewCommitResponse> ReviewCommitAsync(
@@ -50,12 +49,16 @@ public class CodeReviewService : ICodeReviewService
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt(GetCodeReviewSystemPrompt(request.Options))
.RegisterPlugin<ReviewCommitPlugin>()
.Build();
var prompt = BuildCommitReviewPrompt(request);
var response = await conversation.SendAsync(prompt, cancellationToken);
return ParseCommitReviewResponse(response.FinalMessage ?? "");
var response = await conversation.SendForStructuredOutputAsync<ReviewCommitResult>(
prompt, "ReviewCommit", cancellationToken);
var result = PluginHelpers.DeserializeOutput<ReviewCommitResult>(response.StructuredOutput);
return MapCommitResult(result);
}
private static string GetCodeReviewSystemPrompt(ReviewOptions? options)
@@ -92,14 +95,6 @@ public class CodeReviewService : ICodeReviewService
5. Consider the broader context and impact of changes
{focusText}
Output Format:
Provide your review in a structured format:
- SUMMARY: A brief overview of the changes and overall assessment
- VERDICT: APPROVE, REQUEST_CHANGES, or COMMENT
- COMMENTS: Specific line-by-line feedback with severity levels
- SECURITY: Any security concerns found
- SUGGESTIONS: General improvement suggestions
""";
}
@@ -154,36 +149,105 @@ public class CodeReviewService : ICodeReviewService
return sb.ToString();
}
private static ReviewPullRequestResponse ParsePullRequestReviewResponse(string response)
private static ReviewPullRequestResponse MapPullRequestResult(ReviewPullRequestResult result)
{
// TODO: Implement structured parsing using JSON mode or regex extraction
// For now, return a basic response
var result = new ReviewPullRequestResponse
var response = new ReviewPullRequestResponse
{
Summary = response,
Verdict = ReviewVerdict.Comment,
EstimatedReviewMinutes = 5
Summary = result.Summary ?? "",
Verdict = ParseVerdict(result.Verdict),
EstimatedReviewMinutes = result.EstimatedReviewMinutes
};
// Parse verdict from response
if (response.Contains("APPROVE", StringComparison.OrdinalIgnoreCase) &&
!response.Contains("REQUEST_CHANGES", StringComparison.OrdinalIgnoreCase))
if (result.Comments != null)
{
result.Verdict = ReviewVerdict.Approve;
}
else if (response.Contains("REQUEST_CHANGES", StringComparison.OrdinalIgnoreCase))
{
result.Verdict = ReviewVerdict.RequestChanges;
foreach (var c in result.Comments)
{
response.Comments.Add(new ReviewComment
{
Path = c.Path ?? "",
Line = c.Line,
EndLine = c.EndLine,
Body = c.Body ?? "",
Severity = ParseSeverity(c.Severity),
Category = c.Category ?? "",
SuggestedFix = c.SuggestedFix ?? ""
});
}
}
return result;
if (result.Suggestions != null)
response.Suggestions.AddRange(result.Suggestions);
if (result.SecurityIssues is { Count: > 0 })
{
response.Security = new SecurityAnalysis
{
RiskScore = result.SecurityRiskScore,
Summary = result.SecuritySummary ?? ""
};
foreach (var si in result.SecurityIssues)
{
response.Security.Issues.Add(new SecurityIssue
{
Path = si.Path ?? "",
Line = si.Line,
IssueType = si.IssueType ?? "",
Description = si.Description ?? "",
Severity = si.Severity ?? "",
Remediation = si.Remediation ?? ""
});
}
}
return response;
}
private static ReviewCommitResponse ParseCommitReviewResponse(string response)
private static ReviewCommitResponse MapCommitResult(ReviewCommitResult result)
{
return new ReviewCommitResponse
var response = new ReviewCommitResponse
{
Summary = response
Summary = result.Summary ?? ""
};
if (result.Comments != null)
{
foreach (var c in result.Comments)
{
response.Comments.Add(new ReviewComment
{
Path = c.Path ?? "",
Line = c.Line,
EndLine = c.EndLine,
Body = c.Body ?? "",
Severity = ParseSeverity(c.Severity),
Category = c.Category ?? "",
SuggestedFix = c.SuggestedFix ?? ""
});
}
}
if (result.Suggestions != null)
response.Suggestions.AddRange(result.Suggestions);
return response;
}
private static ReviewVerdict ParseVerdict(string? verdict) =>
verdict?.ToLowerInvariant() switch
{
"approve" => ReviewVerdict.Approve,
"request_changes" => ReviewVerdict.RequestChanges,
"comment" => ReviewVerdict.Comment,
_ => ReviewVerdict.Comment
};
private static CommentSeverity ParseSeverity(string? severity) =>
severity?.ToLowerInvariant() switch
{
"critical" => CommentSeverity.Critical,
"error" => CommentSeverity.Error,
"warning" => CommentSeverity.Warning,
"info" => CommentSeverity.Info,
_ => CommentSeverity.Info
};
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered documentation generation.
/// Implementation of AI-powered documentation generation using MarketAlly.AIPlugin structured output.
/// </summary>
public class DocumentationService : IDocumentationService
{
@@ -61,6 +62,7 @@ public class DocumentationService : IDocumentationService
Be concise but thorough.
""")
.RegisterPlugin<GenerateDocumentationPlugin>()
.Build();
var prompt = $"""
@@ -71,12 +73,29 @@ public class DocumentationService : IDocumentationService
```
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<GenerateDocumentationResult>(
prompt, "GenerateDocumentation", cancellationToken);
return new GenerateDocumentationResponse
var result = PluginHelpers.DeserializeOutput<GenerateDocumentationResult>(response.StructuredOutput);
var protoResponse = new GenerateDocumentationResponse
{
Documentation = response.FinalMessage ?? ""
Documentation = result.Documentation ?? ""
};
if (result.Sections != null)
{
foreach (var s in result.Sections)
{
protoResponse.Sections.Add(new DocumentationSection
{
Title = s.Title ?? "",
Content = s.Content ?? ""
});
}
}
return protoResponse;
}
public async Task<GenerateCommitMessageResponse> GenerateCommitMessageAsync(
@@ -110,9 +129,8 @@ public class DocumentationService : IDocumentationService
Analyze the changes and write an appropriate commit message.
Focus on WHAT changed and WHY, not HOW.
Also provide 2-3 alternative messages.
""")
.RegisterPlugin<GenerateCommitMessagePlugin>()
.Build();
var sb = new System.Text.StringBuilder();
@@ -128,26 +146,19 @@ public class DocumentationService : IDocumentationService
sb.AppendLine();
}
var response = await conversation.SendAsync(sb.ToString(), cancellationToken);
var responseContent = response.FinalMessage ?? "";
var response = await conversation.SendForStructuredOutputAsync<GenerateCommitMessageResult>(
sb.ToString(), "GenerateCommitMessage", cancellationToken);
// Parse response - first line is primary, rest are alternatives
var lines = responseContent.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var result = new GenerateCommitMessageResponse
var result = PluginHelpers.DeserializeOutput<GenerateCommitMessageResult>(response.StructuredOutput);
var protoResponse = new GenerateCommitMessageResponse
{
Message = lines.Length > 0 ? lines[0].Trim() : responseContent.Trim()
Message = result.Message ?? ""
};
// Extract alternatives if present
for (var i = 1; i < lines.Length && result.Alternatives.Count < 3; i++)
{
var line = lines[i].Trim();
if (!string.IsNullOrEmpty(line) && !line.StartsWith('#') && !line.StartsWith('-'))
{
result.Alternatives.Add(line);
}
}
if (result.Alternatives != null)
protoResponse.Alternatives.AddRange(result.Alternatives);
return result;
return protoResponse;
}
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered issue management.
/// Implementation of AI-powered issue management using MarketAlly.AIPlugin structured output.
/// </summary>
public class IssueService : IIssueService
{
@@ -40,15 +41,9 @@ public class IssueService : IIssueService
2. Category: bug, feature, question, docs, enhancement, etc.
3. Suggested labels from the available labels: {{availableLabels}}
4. A brief summary of the issue
Respond in JSON format:
{
"priority": "...",
"category": "...",
"suggested_labels": ["..."],
"summary": "..."
}
5. Whether this is a duplicate of an existing issue
""")
.RegisterPlugin<TriageIssuePlugin>()
.Build();
var prompt = $"""
@@ -61,10 +56,27 @@ public class IssueService : IIssueService
Existing labels: {string.Join(", ", request.ExistingLabels)}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<TriageIssueResult>(
prompt, "TriageIssue", cancellationToken);
// Parse JSON response
return ParseTriageResponse(response.FinalMessage ?? "");
var result = PluginHelpers.DeserializeOutput<TriageIssueResult>(response.StructuredOutput);
var protoResponse = new TriageIssueResponse
{
Priority = result.Priority ?? "medium",
Category = result.Category ?? "bug",
Summary = result.Summary ?? "",
IsDuplicate = result.IsDuplicate,
DuplicateOf = result.DuplicateOf
};
if (result.SuggestedLabels != null)
protoResponse.SuggestedLabels.AddRange(result.SuggestedLabels);
if (result.SuggestedAssignees != null)
protoResponse.SuggestedAssignees.AddRange(result.SuggestedAssignees);
return protoResponse;
}
public async Task<SuggestLabelsResponse> SuggestLabelsAsync(
@@ -78,14 +90,8 @@ public class IssueService : IIssueService
Available labels: {{string.Join(", ", request.AvailableLabels)}}
For each suggested label, provide a confidence score (0.0-1.0) and reason.
Respond in JSON format:
{
"suggestions": [
{"label": "...", "confidence": 0.9, "reason": "..."}
]
}
""")
.RegisterPlugin<SuggestLabelsPlugin>()
.Build();
var prompt = $"""
@@ -95,9 +101,27 @@ public class IssueService : IIssueService
{request.Body}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<SuggestLabelsResult>(
prompt, "SuggestLabels", cancellationToken);
return ParseSuggestLabelsResponse(response.FinalMessage ?? "", request.AvailableLabels);
var result = PluginHelpers.DeserializeOutput<SuggestLabelsResult>(response.StructuredOutput);
var protoResponse = new SuggestLabelsResponse();
if (result.Suggestions != null)
{
foreach (var s in result.Suggestions)
{
protoResponse.Suggestions.Add(new LabelSuggestion
{
Label = s.Label ?? "",
Confidence = s.Confidence,
Reason = s.Reason ?? ""
});
}
}
return protoResponse;
}
public async Task<GenerateIssueResponseResponse> GenerateResponseAsync(
@@ -120,6 +144,7 @@ public class IssueService : IIssueService
If you need more information, include follow-up questions.
""")
.RegisterPlugin<GenerateIssueResponsePlugin>()
.Build();
var sb = new System.Text.StringBuilder();
@@ -140,56 +165,19 @@ public class IssueService : IIssueService
sb.AppendLine();
sb.AppendLine("Please generate an appropriate response.");
var response = await conversation.SendAsync(sb.ToString(), cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<GenerateIssueResponseResult>(
sb.ToString(), "GenerateIssueResponse", cancellationToken);
return new GenerateIssueResponseResponse
{
Response = response.FinalMessage ?? ""
};
}
var result = PluginHelpers.DeserializeOutput<GenerateIssueResponseResult>(response.StructuredOutput);
private static TriageIssueResponse ParseTriageResponse(string response)
{
// TODO: Implement proper JSON parsing
var result = new TriageIssueResponse
var protoResponse = new GenerateIssueResponseResponse
{
Priority = "medium",
Category = "bug",
Summary = response
Response = result.Response ?? ""
};
// Simple extraction (improve with JSON parsing)
if (response.Contains("\"priority\":", StringComparison.OrdinalIgnoreCase))
{
if (response.Contains("critical", StringComparison.OrdinalIgnoreCase))
result.Priority = "critical";
else if (response.Contains("high", StringComparison.OrdinalIgnoreCase))
result.Priority = "high";
else if (response.Contains("low", StringComparison.OrdinalIgnoreCase))
result.Priority = "low";
}
if (result.FollowUpQuestions != null)
protoResponse.FollowUpQuestions.AddRange(result.FollowUpQuestions);
return result;
}
private static SuggestLabelsResponse ParseSuggestLabelsResponse(string response, IEnumerable<string> availableLabels)
{
var result = new SuggestLabelsResponse();
// Simple heuristic: check which available labels appear in response
foreach (var label in availableLabels)
{
if (response.Contains(label, StringComparison.OrdinalIgnoreCase))
{
result.Suggestions.Add(new LabelSuggestion
{
Label = label,
Confidence = 0.8f,
Reason = "Label mentioned in AI analysis"
});
}
}
return result;
return protoResponse;
}
}